mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a15914bf | ||
|
|
45527eed29 | ||
|
|
100086a276 | ||
|
|
fc6721ca8e | ||
|
|
4b21f38650 | ||
|
|
bf8d9672e1 | ||
|
|
5374dd47c5 | ||
|
|
29dfcbb584 | ||
|
|
c9321b9028 | ||
|
|
f48c4512d3 | ||
|
|
d3d0100822 | ||
|
|
acaf6b7054 | ||
|
|
c2fec6b51c | ||
|
|
705ed47a0b | ||
|
|
192a283c9a | ||
|
|
c74b1b08eb | ||
|
|
7c85f98c27 | ||
|
|
1e6f8909ec | ||
|
|
008fe23585 | ||
|
|
6af0511e2b | ||
|
|
6dbb0a5bb4 | ||
|
|
21af304c7d | ||
|
|
94af83680e | ||
|
|
cdb51e6a41 | ||
|
|
cbcdb2d29e | ||
|
|
0fdcfd0f4c | ||
|
|
414496dda7 | ||
|
|
55011b7258 | ||
|
|
bf0af178bd | ||
|
|
1b7940c91e | ||
|
|
2f4c4aa614 | ||
|
|
052ba7be07 | ||
|
|
c67d3523ae | ||
|
|
83ce6a9602 | ||
|
|
50fdcbaf57 | ||
|
|
67b0282813 | ||
|
|
0009e330af | ||
|
|
a4b9539b62 | ||
|
|
b7990908fe | ||
|
|
84bae0f42a |
@@ -3,6 +3,7 @@
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "2"
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
workflow-samples
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
- name: Build dotnet solutions
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -281,7 +281,7 @@ jobs:
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run fmt, lint, pyright in parallel across packages
|
||||
- name: Run syntax and pyright across packages
|
||||
run: uv run poe check-packages
|
||||
|
||||
samples-markdown:
|
||||
@@ -104,10 +104,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run samples lint
|
||||
run: uv run poe samples-lint
|
||||
- name: Run samples syntax check
|
||||
run: uv run poe samples-syntax
|
||||
- name: Run samples checks
|
||||
run: uv run poe check -S
|
||||
- name: Run markdown code lint
|
||||
run: uv run poe markdown-code-lint
|
||||
|
||||
@@ -140,4 +138,4 @@ jobs:
|
||||
- name: Run Mypy
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
|
||||
run: uv run poe ci-mypy
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
|
||||
name: Python - Dependency Range Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
dependency-range-validation:
|
||||
name: Dependency Range Validation
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
|
||||
# then we will have to reevaluate.
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Run dependency range validation
|
||||
id: validate_ranges
|
||||
# Keep workflow running so we can still publish diagnostics from this run.
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Upload dependency range report
|
||||
# Always publish the report so failures are inspectable even when validation fails.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create issues for failed dependency candidates
|
||||
# Always process the report so failed candidates create actionable tracking issues.
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
core.warning(`No dependency range report found at ${reportPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
||||
const dependencyFailures = []
|
||||
|
||||
for (const packageResult of report.packages ?? []) {
|
||||
for (const dependency of packageResult.dependencies ?? []) {
|
||||
const candidateVersions = new Set(dependency.candidate_versions ?? [])
|
||||
const failedAttempts = (dependency.attempts ?? []).filter(
|
||||
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
|
||||
)
|
||||
if (!failedAttempts.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const failuresByVersion = new Map()
|
||||
for (const attempt of failedAttempts) {
|
||||
const version = attempt.trial_upper || "unknown"
|
||||
if (!failuresByVersion.has(version)) {
|
||||
failuresByVersion.set(version, attempt.error || "No error output captured.")
|
||||
}
|
||||
}
|
||||
|
||||
dependencyFailures.push({
|
||||
packageName: packageResult.package_name,
|
||||
projectPath: packageResult.project_path,
|
||||
dependencyName: dependency.name,
|
||||
originalRequirements: dependency.original_requirements ?? [],
|
||||
finalRequirements: dependency.final_requirements ?? [],
|
||||
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!dependencyFailures.length) {
|
||||
core.info("No failing dependency candidates found.")
|
||||
return
|
||||
}
|
||||
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const openIssueTitles = new Set(
|
||||
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
|
||||
)
|
||||
|
||||
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
|
||||
|
||||
for (const failure of dependencyFailures) {
|
||||
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
|
||||
if (openIssueTitles.has(title)) {
|
||||
core.info(`Issue already exists: ${title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const visibleFailures = failure.failedVersions.slice(0, 5)
|
||||
const omittedCount = failure.failedVersions.length - visibleFailures.length
|
||||
const failureDetails = visibleFailures
|
||||
.map(
|
||||
(entry) =>
|
||||
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
|
||||
)
|
||||
.join("\n\n")
|
||||
|
||||
const body = [
|
||||
"Automated dependency range validation found candidate versions that failed checks.",
|
||||
"",
|
||||
`- Package: \`${failure.packageName}\``,
|
||||
`- Project path: \`${failure.projectPath}\``,
|
||||
`- Dependency: \`${failure.dependencyName}\``,
|
||||
`- Original requirements: ${
|
||||
failure.originalRequirements.length
|
||||
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
`- Final requirements after run: ${
|
||||
failure.finalRequirements.length
|
||||
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
"",
|
||||
"### Failed versions and errors",
|
||||
failureDetails,
|
||||
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
|
||||
"",
|
||||
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
|
||||
].join("\n")
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
})
|
||||
openIssueTitles.add(title)
|
||||
core.info(`Created issue: ${title}`)
|
||||
}
|
||||
|
||||
- name: Refresh lockfile
|
||||
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: uv lock --upgrade
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dependency updates
|
||||
id: commit_updates
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update dependency ranges"
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
# Only open/update PRs for validated updates to keep automation branches trustworthy.
|
||||
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
PR_TITLE="Python: chore: update dependency ranges"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
This PR was generated by the dependency range validation workflow.
|
||||
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Python - Dev Dependency Upgrade
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
upgrade-dev-dependencies:
|
||||
name: Upgrade Dev Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Upgrade dev dependencies and validate workspace
|
||||
run: uv run poe upgrade-dev-dependencies
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dev dependency updates
|
||||
id: commit_updates
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dev dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -F- <<'EOF'
|
||||
Python: chore: upgrade dev dependencies
|
||||
EOF
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
if: steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
PR_TITLE="Python: chore: upgrade dev dependencies"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
### Motivation and Context
|
||||
|
||||
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
|
||||
|
||||
### Description
|
||||
|
||||
- Ran `uv run poe upgrade-dev-dependencies`
|
||||
- Refreshed dev dependency pins in workspace `pyproject.toml` files
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
|
||||
|
||||
### Contribution Checklist
|
||||
|
||||
- [x] The code builds clean without any errors or warnings
|
||||
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
||||
- [x] All unit tests pass, and I have added new tests where possible
|
||||
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -48,9 +48,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ jobs:
|
||||
- name: Run lab tests
|
||||
run: cd packages/lab && uv run poe test
|
||||
|
||||
- name: Run resource-intensive lab tests
|
||||
run: cd packages/lab && uv run pytest -m "resource_intensive and not integration" --junitxml=test-results-resource-intensive.xml
|
||||
|
||||
- name: Run lab lint
|
||||
run: cd packages/lab && uv run poe lint
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-05-end-to-end
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@v1.2.0
|
||||
uses: MishaKav/pytest-coverage-comment@v1.6.0
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
|
||||
@@ -32,17 +32,17 @@ jobs:
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
run: uv run poe test -A
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -205,6 +205,9 @@ WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
**/tmpclaude*
|
||||
# Dependency-bound validation reports
|
||||
python/scripts/dependency-*-results.json
|
||||
python/scripts/dependencies/dependency-*-results.json
|
||||
|
||||
# Azurite storage emulator files
|
||||
*/__azurite_db_blob__.json*
|
||||
|
||||
@@ -4,8 +4,8 @@ status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# Agent Run Responses Design
|
||||
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
|
||||
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
|
||||
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
|
||||
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/user-guide/concepts/streaming/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/api/python/strands.agent.agent/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
|
||||
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
|
||||
@@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
|
||||
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/user-guide/concepts/agents/structured-output/) |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/api/python/strands.agent.agent/) |
|
||||
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
|
||||
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time |
|
||||
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
|
||||
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
|
||||
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
|
||||
| AWS (Strands) | Exposes a `stop_reason` property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) class with options that are tied closely to LLM operations. |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/docs/api/python/strands.types.event_loop/) property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) class with options that are tied closely to LLM operations. |
|
||||
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
|
||||
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
|
||||
|
||||
@@ -120,14 +120,14 @@
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.22.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.22.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.22.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.22.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.16.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.12.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
@@ -149,6 +149,7 @@
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
<PackageVersion Include="ReferenceTrimmer" Version="3.4.5" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-4
@@ -12,13 +12,9 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.Json" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.PowerFx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ public static class Program
|
||||
{
|
||||
Console.WriteLine($"{outputEvent}");
|
||||
}
|
||||
|
||||
if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
|
||||
Console.WriteLine($"Details: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
|
||||
/// <summary>
|
||||
/// A custom executor that uses an AI agent to provide feedback on a slogan.
|
||||
/// </summary>
|
||||
internal sealed class FeedbackExecutor : Executor<SloganResult>
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentSession? _session;
|
||||
|
||||
-1
@@ -14,7 +14,6 @@
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+14
-5
@@ -17,7 +17,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
|
||||
public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
|
||||
{
|
||||
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic);
|
||||
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", SanitizeLogValue(topic));
|
||||
|
||||
const int MaxReviewAttempts = 3;
|
||||
const float ApprovalTimeoutHours = 72;
|
||||
@@ -34,7 +34,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
|
||||
this._logger.LogInformation(
|
||||
"Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}",
|
||||
topic,
|
||||
SanitizeLogValue(topic),
|
||||
instanceId);
|
||||
|
||||
return $"Workflow started with instance ID: {instanceId}";
|
||||
@@ -45,7 +45,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("The instance ID of the workflow to check")] string instanceId,
|
||||
[Description("Whether to include detailed information")] bool includeDetails = true)
|
||||
{
|
||||
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId);
|
||||
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
|
||||
|
||||
// Get the current agent context using the session-static property
|
||||
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
|
||||
@@ -54,7 +54,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId);
|
||||
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(instanceId));
|
||||
return new
|
||||
{
|
||||
instanceId,
|
||||
@@ -78,7 +78,16 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
|
||||
[Description("Feedback to submit")] HumanApprovalResponse feedback)
|
||||
{
|
||||
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId);
|
||||
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
|
||||
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a user-provided value for safe inclusion in log entries
|
||||
/// by removing control characters that could be used for log forging.
|
||||
/// </summary>
|
||||
private static string SanitizeLogValue(string value) =>
|
||||
value
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
+20
-4
@@ -157,8 +157,8 @@ public sealed class FunctionTriggers
|
||||
|
||||
this._logger.LogInformation(
|
||||
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
|
||||
conversationId,
|
||||
cursor ?? "(beginning)");
|
||||
SanitizeLogValue(conversationId),
|
||||
SanitizeLogValue(cursor) ?? "(beginning)");
|
||||
|
||||
// Check Accept header to determine response format
|
||||
// text/plain = raw text output (ideal for terminals)
|
||||
@@ -205,7 +205,7 @@ public sealed class FunctionTriggers
|
||||
{
|
||||
if (chunk.Error != null)
|
||||
{
|
||||
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
|
||||
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error);
|
||||
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
|
||||
break;
|
||||
}
|
||||
@@ -224,7 +224,7 @@ public sealed class FunctionTriggers
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
|
||||
this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId));
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
@@ -316,4 +316,20 @@ public sealed class FunctionTriggers
|
||||
|
||||
await response.WriteAsync(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a user-provided value for safe inclusion in log entries
|
||||
/// by removing control characters that could be used for log forging.
|
||||
/// </summary>
|
||||
private static string? SanitizeLogValue(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
|
||||
// The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire.
|
||||
|
||||
#pragma warning disable MEAI001 // HostedMcpServerTool, HostedMcpServerToolApprovalMode are experimental
|
||||
#pragma warning disable OPENAI001 // GetResponsesClient is experimental
|
||||
|
||||
using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents
|
||||
// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder.
|
||||
|
||||
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
|
||||
|
||||
using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Uses Microsoft Agent Framework with Azure AI Foundry.
|
||||
// Ready for deployment to Foundry Hosted Agent service.
|
||||
|
||||
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="../Directory.Build.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ReferenceTrimmer" PrivateAssets="all" IncludeAssets="build;analyzers;buildTransitive" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -16,12 +16,9 @@
|
||||
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="System.Net.Http.Json" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -33,9 +33,17 @@
|
||||
|
||||
## v1.0.0-preview.251219.1
|
||||
|
||||
- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670))
|
||||
|
||||
## v1.0.0-preview.260311.1
|
||||
|
||||
### Changed
|
||||
|
||||
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
|
||||
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
|
||||
|
||||
NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file.
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.State;
|
||||
|
||||
@@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
|
||||
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
|
||||
Messages = response.Messages
|
||||
.Where(HasSerializableContent)
|
||||
.Select(DurableAgentStateMessage.FromChatMessage)
|
||||
.ToList(),
|
||||
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
|
||||
};
|
||||
}
|
||||
@@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
|
||||
Usage = this.Usage?.ToUsageDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
// Checks whether a ChatMessage has any content that will produce meaningful serialized data.
|
||||
// Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable.
|
||||
// Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and
|
||||
// AdditionalProperties. We keep the message if any base AIContent has annotations or additional
|
||||
// properties set. NOTE: if AIContent gains new serializable properties in the future, this check
|
||||
// should be updated accordingly.
|
||||
private static bool HasSerializableContent(ChatMessage message)
|
||||
{
|
||||
return message.Contents.Any(c =>
|
||||
c.GetType() != typeof(AIContent) ||
|
||||
c.Annotations?.Count > 0 ||
|
||||
c.AdditionalProperties?.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<PackageReference Include="Microsoft.PowerFx.Interpreter" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="System.CodeDom" />
|
||||
<PackageReference Include="System.CodeDom" TreatAsUsed="true" />
|
||||
<PackageReference Include="System.Collections.Immutable" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
|
||||
string classKey = GetClassKey(classSymbol);
|
||||
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
|
||||
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
|
||||
bool configureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
|
||||
// Extract class metadata
|
||||
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
|
||||
@@ -97,7 +97,7 @@ internal static class SemanticAnalyzer
|
||||
return new MethodAnalysisResult(
|
||||
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
|
||||
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
|
||||
isPartialClass, derivesFromExecutor, configureProtocol,
|
||||
isPartialClass, derivesFromExecutor, hasManualConfigureProtocol,
|
||||
classLocation,
|
||||
handler,
|
||||
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
|
||||
@@ -149,7 +149,7 @@ internal static class SemanticAnalyzer
|
||||
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
|
||||
}
|
||||
|
||||
if (first.HasManualConfigureRoutes)
|
||||
if (first.HasManualConfigureProtocol)
|
||||
{
|
||||
allDiagnostics.Add(Diagnostic.Create(
|
||||
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
|
||||
@@ -212,6 +212,7 @@ internal static class SemanticAnalyzer
|
||||
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
|
||||
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
|
||||
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
|
||||
|
||||
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
|
||||
? null
|
||||
@@ -241,6 +242,7 @@ internal static class SemanticAnalyzer
|
||||
isPartialClass,
|
||||
derivesFromExecutor,
|
||||
hasManualConfigureProtocol,
|
||||
baseHasConfigureProtocol,
|
||||
classLocation,
|
||||
typeName,
|
||||
attributeKind));
|
||||
@@ -321,7 +323,7 @@ internal static class SemanticAnalyzer
|
||||
first.GenericParameters,
|
||||
first.IsNested,
|
||||
first.ContainingTypeChain,
|
||||
BaseHasConfigureProtocol: false, // Not relevant for protocol-only
|
||||
first.BaseHasConfigureProtocol,
|
||||
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
|
||||
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
|
||||
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// <summary>
|
||||
/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes.
|
||||
/// Used by the incremental generator pipeline to capture classes that declare protocol types
|
||||
/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented).
|
||||
/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented).
|
||||
/// </summary>
|
||||
/// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param>
|
||||
/// <param name="Namespace">The namespace of the class.</param>
|
||||
@@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param>
|
||||
/// <param name="IsPartialClass">Whether the class is declared as partial.</param>
|
||||
/// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param>
|
||||
/// <param name="HasManualConfigureRoutes">Whether the class has a manually defined ConfigureRoutes method.</param>
|
||||
/// <param name="HasManualConfigureProtocol">Whether the class has a manually defined ConfigureProtocol method.</param>
|
||||
/// <param name="BaseHasConfigureProtocol">Whether a base class already overrides ConfigureProtocol.</param>
|
||||
/// <param name="ClassLocation">Location info for diagnostics.</param>
|
||||
/// <param name="TypeName">The fully qualified type name from the attribute.</param>
|
||||
/// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param>
|
||||
@@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo(
|
||||
string ContainingTypeChain,
|
||||
bool IsPartialClass,
|
||||
bool DerivesFromExecutor,
|
||||
bool HasManualConfigureRoutes,
|
||||
bool HasManualConfigureProtocol,
|
||||
bool BaseHasConfigureProtocol,
|
||||
DiagnosticLocationInfo? ClassLocation,
|
||||
string TypeName,
|
||||
ProtocolAttributeKind AttributeKind)
|
||||
@@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo(
|
||||
/// </summary>
|
||||
public static ClassProtocolInfo Empty { get; } = new(
|
||||
string.Empty, null, string.Empty, null, false, string.Empty,
|
||||
false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
|
||||
false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// Uses value-equatable types to support incremental generator caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes)
|
||||
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol)
|
||||
/// is extracted here but validated once per class in CombineMethodResults to avoid
|
||||
/// redundant validation work when a class has multiple handlers.
|
||||
/// </remarks>
|
||||
@@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult(
|
||||
// Class-level facts (used for validation in CombineMethodResults)
|
||||
bool IsPartialClass,
|
||||
bool DerivesFromExecutor,
|
||||
bool HasManualConfigureRoutes,
|
||||
bool HasManualConfigureProtocol,
|
||||
|
||||
// Class location for diagnostics (value-equatable)
|
||||
DiagnosticLocationInfo? ClassLocation,
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
internal sealed class FanInEdgeState
|
||||
{
|
||||
private List<PortableMessageEnvelope> _pendingMessages;
|
||||
private readonly object _syncLock = new();
|
||||
|
||||
public FanInEdgeState(FanInEdgeData fanInEdge)
|
||||
{
|
||||
this.SourceIds = fanInEdge.SourceIds.ToArray();
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
|
||||
this._pendingMessages = [];
|
||||
this.PendingMessages = [];
|
||||
}
|
||||
|
||||
public string[] SourceIds { get; }
|
||||
public HashSet<string> Unseen { get; private set; }
|
||||
public List<PortableMessageEnvelope> PendingMessages => this._pendingMessages;
|
||||
public List<PortableMessageEnvelope> PendingMessages { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public FanInEdgeState(string[] sourceIds, HashSet<string> unseen, List<PortableMessageEnvelope> pendingMessages)
|
||||
@@ -29,28 +29,35 @@ internal sealed class FanInEdgeState
|
||||
this.SourceIds = sourceIds;
|
||||
this.Unseen = unseen;
|
||||
|
||||
this._pendingMessages = pendingMessages;
|
||||
this.PendingMessages = pendingMessages;
|
||||
}
|
||||
|
||||
public IEnumerable<IGrouping<ExecutorIdentity, MessageEnvelope>>? ProcessMessage(string sourceId, MessageEnvelope envelope)
|
||||
{
|
||||
this.PendingMessages.Add(new(envelope));
|
||||
this.Unseen.Remove(sourceId);
|
||||
List<PortableMessageEnvelope>? takenMessages = null;
|
||||
|
||||
if (this.Unseen.Count == 0)
|
||||
// Serialize concurrent calls from parallel executor tasks during superstep execution.
|
||||
// NOTE - IMPORTANT: If this ProcessMessage method ever becomes async, replace this lock with an async friendly solution to avoid deadlocks.
|
||||
lock (this._syncLock)
|
||||
{
|
||||
List<PortableMessageEnvelope> takenMessages = Interlocked.Exchange(ref this._pendingMessages, []);
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
this.PendingMessages.Add(new(envelope));
|
||||
this.Unseen.Remove(sourceId);
|
||||
|
||||
if (takenMessages.Count == 0)
|
||||
if (this.Unseen.Count == 0)
|
||||
{
|
||||
return null;
|
||||
takenMessages = this.PendingMessages;
|
||||
this.PendingMessages = [];
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
}
|
||||
|
||||
return takenMessages.Select(portable => portable.ToMessageEnvelope())
|
||||
.GroupBy(keySelector: messageEnvelope => messageEnvelope.Source);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (takenMessages is null || takenMessages.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return takenMessages
|
||||
.Select(portable => portable.ToMessageEnvelope())
|
||||
.GroupBy(messageEnvelope => messageEnvelope.Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="CompactionStrategy"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class ChatStrategyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an <see cref="IChatReducer"/> that applies this <see cref="CompactionStrategy"/> to reduce a list of messages.
|
||||
/// </summary>
|
||||
/// <param name="strategy">The compaction strategy to wrap as an <see cref="IChatReducer"/>.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IChatReducer"/> that, on each call to <see cref="IChatReducer.ReduceAsync"/>, builds a
|
||||
/// <see cref="CompactionMessageIndex"/> from the supplied messages and applies the strategy's compaction logic,
|
||||
/// returning the resulting included messages.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This allows any <see cref="CompactionStrategy"/> to be used wherever an <see cref="IChatReducer"/> is expected,
|
||||
/// bridging the compaction pipeline into systems bound to the <c>Microsoft.Extensions.AI</c> <see cref="IChatReducer"/> contract.
|
||||
/// </remarks>
|
||||
public static IChatReducer AsChatReducer(this CompactionStrategy strategy)
|
||||
{
|
||||
Throw.IfNull(strategy);
|
||||
|
||||
return new CompactionStrategyChatReducer(strategy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> adapter that delegates to a <see cref="CompactionStrategy"/>.
|
||||
/// </summary>
|
||||
private sealed class CompactionStrategyChatReducer : IChatReducer
|
||||
{
|
||||
private readonly CompactionStrategy _strategy;
|
||||
|
||||
public CompactionStrategyChatReducer(CompactionStrategy strategy)
|
||||
{
|
||||
this._strategy = strategy;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([.. messages]);
|
||||
await this._strategy.CompactAsync(index, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return index.GetIncludedMessages();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
@@ -30,6 +31,12 @@ namespace Microsoft.Agents.AI.Compaction;
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A custom <see cref="ToolCallFormatter"/> can be supplied to override the default YAML-like
|
||||
/// summary format. The formatter receives the <see cref="CompactionMessageGroup"/> being collapsed
|
||||
/// and must return the replacement summary string. <see cref="DefaultToolCallFormatter"/> is the
|
||||
/// built-in default and can be reused inside a custom formatter when needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
|
||||
/// </para>
|
||||
@@ -62,7 +69,10 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
public ToolResultCompactionStrategy(
|
||||
CompactionTrigger trigger,
|
||||
int minimumPreservedGroups = DefaultMinimumPreserved,
|
||||
CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
|
||||
@@ -74,6 +84,13 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
/// </summary>
|
||||
public int MinimumPreservedGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional custom formatter that converts a <see cref="CompactionMessageGroup"/> into a summary string.
|
||||
/// When <see langword="null"/>, <see cref="DefaultToolCallFormatter"/> is used, which produces a YAML-like
|
||||
/// block listing each tool name and its results.
|
||||
/// </summary>
|
||||
public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -120,7 +137,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
int idx = eligibleIndices[e] + offset;
|
||||
CompactionMessageGroup group = index.Groups[idx];
|
||||
|
||||
string summary = BuildToolCallSummary(group);
|
||||
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
|
||||
|
||||
// Exclude the original group and insert a collapsed replacement
|
||||
group.IsExcluded = true;
|
||||
@@ -145,14 +162,18 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a concise summary string for a tool call group, including tool names,
|
||||
/// The default formatter that produces a YAML-like summary of tool call groups, including tool names,
|
||||
/// results, and deduplication counts for repeated tool names.
|
||||
/// </summary>
|
||||
private static string BuildToolCallSummary(CompactionMessageGroup group)
|
||||
/// <remarks>
|
||||
/// This is the formatter used when no custom <see cref="ToolCallFormatter"/> is supplied.
|
||||
/// It can be referenced directly in a custom formatter to augment or wrap the default output.
|
||||
/// </remarks>
|
||||
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
|
||||
{
|
||||
// Collect function calls (callId, name) and results (callId → result text)
|
||||
List<(string CallId, string Name)> functionCalls = [];
|
||||
Dictionary<string, string> resultsByCallId = new();
|
||||
Dictionary<string, string> resultsByCallId = [];
|
||||
List<string> plainTextResults = [];
|
||||
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
@@ -187,7 +208,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
// grouping by tool name while preserving first-seen order.
|
||||
int plainTextIdx = 0;
|
||||
List<string> orderedNames = [];
|
||||
Dictionary<string, List<string>> groupedResults = new();
|
||||
Dictionary<string, List<string>> groupedResults = [];
|
||||
|
||||
foreach ((string callId, string name) in functionCalls)
|
||||
{
|
||||
|
||||
@@ -175,15 +175,23 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
try
|
||||
{
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
|
||||
"The provided SkillsInstructionPrompt is not a valid format string.",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
|
||||
if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
|
||||
nameof(options));
|
||||
}
|
||||
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
|
||||
-1
@@ -8,7 +8,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask.State;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Tests.Unit.State;
|
||||
|
||||
public sealed class DurableAgentStateResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromResponseDropsMessagesContainingOnlyOpaqueContent()
|
||||
{
|
||||
// Arrange: one message with real text, one with only opaque AIContent
|
||||
ChatMessage usefulMessage = new(ChatRole.Assistant, "Hello, world!")
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
ChatMessage opaqueOnlyMessage = new(ChatRole.Assistant, [
|
||||
new AIContent
|
||||
{
|
||||
RawRepresentation = new { kind = "sessionEvent", sessionId = "s123" }
|
||||
}])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { usefulMessage, opaqueOnlyMessage })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-123", response);
|
||||
|
||||
// Assert: only the useful message survives
|
||||
DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages);
|
||||
Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role);
|
||||
|
||||
// Round-trip to verify the content is correct
|
||||
AgentResponse convertedResponse = durableResponse.ToResponse();
|
||||
ChatMessage convertedMessage = Assert.Single(convertedResponse.Messages);
|
||||
TextContent textContent = Assert.IsType<TextContent>(Assert.Single(convertedMessage.Contents));
|
||||
Assert.Equal("Hello, world!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsMessagesWithMixedContent()
|
||||
{
|
||||
// Arrange: one message with both real text and opaque AIContent
|
||||
ChatMessage mixedMessage = new(ChatRole.Assistant, [
|
||||
new TextContent("Some useful text"),
|
||||
new AIContent { RawRepresentation = new { kind = "metadata" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { mixedMessage })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-456", response);
|
||||
|
||||
// Assert: the message is kept because it contains at least one serializable content
|
||||
DurableAgentStateMessage durableMessage = Assert.Single(durableResponse.Messages);
|
||||
Assert.Equal(ChatRole.Assistant.Value, durableMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseDropsAllMessagesWhenAllAreOpaque()
|
||||
{
|
||||
// Arrange: all messages contain only opaque AIContent
|
||||
ChatMessage opaque1 = new(ChatRole.Assistant, [
|
||||
new AIContent { RawRepresentation = new { kind = "event1" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
ChatMessage opaque2 = new(ChatRole.Assistant, [
|
||||
new AIContent { RawRepresentation = new { kind = "event2" } }])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddSeconds(1)
|
||||
};
|
||||
|
||||
AgentResponse response = new(new List<ChatMessage> { opaque1, opaque2 })
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-789", response);
|
||||
|
||||
// Assert: no messages stored
|
||||
Assert.Empty(durableResponse.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsBaseAIContentWithAnnotations()
|
||||
{
|
||||
// Arrange: base AIContent with annotations should be kept
|
||||
AIContent contentWithAnnotations = new()
|
||||
{
|
||||
RawRepresentation = new { kind = "event" },
|
||||
Annotations = [new AIAnnotation() { AdditionalProperties = new() { ["cite"] = "ref-1" } }]
|
||||
};
|
||||
ChatMessage message = new(ChatRole.Assistant, [contentWithAnnotations])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-ann", response);
|
||||
|
||||
// Assert: message is kept because the AIContent has annotations
|
||||
Assert.Single(durableResponse.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromResponseKeepsBaseAIContentWithAdditionalProperties()
|
||||
{
|
||||
// Arrange: base AIContent with additional properties should be kept
|
||||
AIContent contentWithProps = new()
|
||||
{
|
||||
RawRepresentation = new { kind = "event" },
|
||||
AdditionalProperties = new() { ["custom_key"] = "custom_value" }
|
||||
};
|
||||
ChatMessage message = new(ChatRole.Assistant, [contentWithProps])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
AgentResponse response = new([message]) { CreatedAt = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
DurableAgentStateResponse durableResponse = DurableAgentStateResponse.FromResponse("corr-props", response);
|
||||
|
||||
// Assert: message is kept because the AIContent has additional properties
|
||||
Assert.Single(durableResponse.Messages);
|
||||
}
|
||||
}
|
||||
-1
@@ -10,7 +10,6 @@
|
||||
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
-1
@@ -6,7 +6,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+8
-2
@@ -21,6 +21,12 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
private const string RedisPort = "6379";
|
||||
|
||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||
|
||||
#if DEBUG
|
||||
private const string BuildConfiguration = "Debug";
|
||||
#else
|
||||
private const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
private static readonly HttpClient s_sharedHttpClient = new();
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
@@ -825,7 +831,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
ProcessStartInfo buildInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"build -f {s_dotnetTargetFramework}",
|
||||
Arguments = $"build -f {s_dotnetTargetFramework} -c {BuildConfiguration}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
@@ -855,7 +861,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --no-build -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||
Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
+7
-1
@@ -20,6 +20,12 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
private const string DtsPort = "8080";
|
||||
|
||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||
|
||||
#if DEBUG
|
||||
private const string BuildConfiguration = "Debug";
|
||||
#else
|
||||
private const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
private static readonly HttpClient s_sharedHttpClient = new();
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
@@ -437,7 +443,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||
Arguments = $"run -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
+36
@@ -127,6 +127,42 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange -- valid format string but missing the required placeholder
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "No placeholder here"
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
||||
Assert.Contains("{0}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
|
||||
{
|
||||
// Arrange — valid custom template with {0} placeholder
|
||||
this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
|
||||
};
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — the custom template wraps the skill list
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("== Skills ==", result.Instructions);
|
||||
Assert.Contains("custom-tpl-skill", result.Instructions);
|
||||
Assert.Contains("== End ==", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatStrategyExtensions"/> class.
|
||||
/// </summary>
|
||||
public class ChatStrategyExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AsChatReducerNullStrategyThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((CompactionStrategy)null!).AsChatReducer());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatReducerReturnsIChatReducer()
|
||||
{
|
||||
// Arrange
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
|
||||
|
||||
// Act
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(reducer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync()
|
||||
{
|
||||
// Arrange — trigger never fires, so no compaction occurs
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi!"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(messages, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync()
|
||||
{
|
||||
// Arrange — reducer keeps only the last message
|
||||
ChatReducerCompactionStrategy strategy = new(
|
||||
new TakeLastReducer(1),
|
||||
CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Response 1"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> resultList = [.. result];
|
||||
Assert.Single(resultList);
|
||||
Assert.Equal("Second", resultList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync()
|
||||
{
|
||||
// Arrange
|
||||
using CancellationTokenSource cts = new();
|
||||
CancellationToken capturedToken = default;
|
||||
|
||||
CapturingReducer capturingReducer = new(token => capturedToken = token);
|
||||
ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.User, "World"),
|
||||
];
|
||||
|
||||
// Act
|
||||
await reducer.ReduceAsync(messages, cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, capturedToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync([], CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that returns messages unchanged.
|
||||
/// </summary>
|
||||
private sealed class IdentityReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that keeps only the last <c>n</c> messages.
|
||||
/// </summary>
|
||||
private sealed class TakeLastReducer : IChatReducer
|
||||
{
|
||||
private readonly int _count;
|
||||
|
||||
public TakeLastReducer(int count) => this._count = count;
|
||||
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages.Reverse().Take(this._count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that captures the <see cref="CancellationToken"/> passed to <see cref="ReduceAsync"/>.
|
||||
/// </summary>
|
||||
private sealed class CapturingReducer : IChatReducer
|
||||
{
|
||||
private readonly Action<CancellationToken> _capture;
|
||||
|
||||
public CapturingReducer(Action<CancellationToken> capture) => this._capture = capture;
|
||||
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._capture(cancellationToken);
|
||||
IEnumerable<ChatMessage> reducedMessages = [messages.Reverse().First()];
|
||||
return Task.FromResult(reducedMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
@@ -348,4 +349,90 @@ public class ToolResultCompactionStrategyTests
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncUsesCustomFormatterAsync()
|
||||
{
|
||||
// Arrange — custom formatter that produces a collapsed message count
|
||||
static string CustomFormatter(CompactionMessageGroup group) =>
|
||||
$"[Collapsed: {group.Messages.Count} messages]";
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1)
|
||||
{
|
||||
ToolCallFormatter = CustomFormatter,
|
||||
};
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — custom formatter output used instead of default YAML-like format
|
||||
Assert.True(result);
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);
|
||||
|
||||
// Assert — ToolCallFormatter is null when no custom formatter is provided
|
||||
Assert.Null(strategy.ToolCallFormatter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.Always)
|
||||
{
|
||||
ToolCallFormatter = customFormatter
|
||||
};
|
||||
|
||||
// Assert — ToolCallFormatter is the injected custom function
|
||||
Assert.Same(customFormatter, strategy.ToolCallFormatter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
|
||||
{
|
||||
// Arrange — custom formatter that wraps the default output
|
||||
static string WrappingFormatter(CompactionMessageGroup group) =>
|
||||
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1)
|
||||
{
|
||||
ToolCallFormatter = WrappingFormatter
|
||||
};
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — wrapped default output
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -16,7 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
|
||||
+81
-2
@@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides()
|
||||
public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides()
|
||||
{
|
||||
// File 1: Partial with one handler
|
||||
var file1 = """
|
||||
@@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests
|
||||
generated.Should().RegisterSentMessageType("string")
|
||||
.And.RegisterSentMessageType("int")
|
||||
.And.RegisterYieldedOutputType("string")
|
||||
.And.RegisterYieldedOutputType("string");
|
||||
.And.RegisterYieldedOutputType("int");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests
|
||||
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall()
|
||||
{
|
||||
// A protocol-only partial executor deriving from Executor<T>
|
||||
// has a base class that already overrides ConfigureProtocol. The generator must emit
|
||||
// "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations
|
||||
// are preserved — not "return protocolBuilder" which silently drops them.
|
||||
var source = """
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace TestNamespace;
|
||||
|
||||
public class FeedbackResult { }
|
||||
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
public partial class FeedbackExecutor : Executor<string>
|
||||
{
|
||||
public FeedbackExecutor() : base("feedback") { }
|
||||
|
||||
public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
}
|
||||
""";
|
||||
|
||||
var result = GeneratorTestHelper.RunGenerator(source);
|
||||
|
||||
result.RunResult.GeneratedTrees.Should().HaveCount(1);
|
||||
result.RunResult.Diagnostics.Should().BeEmpty();
|
||||
|
||||
var generated = result.RunResult.GeneratedTrees[0].ToString();
|
||||
|
||||
// Base class Executor<T> overrides ConfigureProtocol, so the generated override
|
||||
// must chain to base to preserve the inherited handler registration.
|
||||
generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)",
|
||||
because: "Executor<T> overrides ConfigureProtocol, so base must be called to preserve its handler registration");
|
||||
generated.Should().Contain(".SendsMessage<global::TestNamespace.FeedbackResult>()");
|
||||
generated.Should().Contain(".YieldsOutput<string>()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall()
|
||||
{
|
||||
// A protocol-only partial executor deriving directly from Executor (abstract base
|
||||
// with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder"
|
||||
// rather than "return base.ConfigureProtocol(protocolBuilder)".
|
||||
var source = """
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace TestNamespace;
|
||||
|
||||
public class BroadcastMessage { }
|
||||
|
||||
[SendsMessage(typeof(BroadcastMessage))]
|
||||
public partial class BroadcastExecutor : Executor
|
||||
{
|
||||
public BroadcastExecutor() : base("broadcast") { }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = GeneratorTestHelper.RunGenerator(source);
|
||||
|
||||
result.RunResult.GeneratedTrees.Should().HaveCount(1);
|
||||
result.RunResult.Diagnostics.Should().BeEmpty();
|
||||
|
||||
var generated = result.RunResult.GeneratedTrees[0].ToString();
|
||||
|
||||
// Executor's ConfigureProtocol is abstract — no base call needed.
|
||||
generated.Should().Contain("return protocolBuilder",
|
||||
because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed");
|
||||
generated.Should().NotContain("base.ConfigureProtocol");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generic Executor Tests
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -199,4 +200,43 @@ public class EdgeRunnerTests
|
||||
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_FanInEdgeRunner_ConcurrentProcessingAsync()
|
||||
{
|
||||
// Arrange
|
||||
const int SourceCount = 4;
|
||||
const int Iterations = 50;
|
||||
|
||||
string[] sourceIds = Enumerable.Range(0, SourceCount).Select(i => $"source{i}").ToArray();
|
||||
const string SinkId = "sink";
|
||||
|
||||
TestRunContext runContext = new();
|
||||
List<Executor> executors = [.. sourceIds.Select(id => (Executor)new ForwardMessageExecutor<string>(id)), new ForwardMessageExecutor<string>(SinkId)];
|
||||
runContext.ConfigureExecutors(executors);
|
||||
|
||||
FanInEdgeData edgeData = new(sourceIds.ToList(), SinkId, new EdgeId(0), null);
|
||||
FanInEdgeRunner runner = new(runContext, edgeData);
|
||||
|
||||
for (int iteration = 0; iteration < Iterations; iteration++)
|
||||
{
|
||||
// Act: send messages from all sources concurrently
|
||||
using Barrier barrier = new(SourceCount);
|
||||
Task<DeliveryMapping?>[] tasks = sourceIds.Select(sourceId => Task.Run(async () =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return await runner.ChaseEdgeAsync(new($"msg-from-{sourceId}", sourceId), stepTracer: null, CancellationToken.None);
|
||||
})).ToArray();
|
||||
|
||||
DeliveryMapping?[] results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: exactly one task should return a non-null mapping with all messages
|
||||
DeliveryMapping?[] nonNullResults = results.Where(r => r is not null).ToArray();
|
||||
nonNullResults.Should().HaveCount(1, $"iteration {iteration}: exactly one thread should release the batch");
|
||||
|
||||
DeliveryMapping mapping = nonNullResults[0]!;
|
||||
HashSet<object> expectedMessages = [.. sourceIds.Select(id => (object)$"msg-from-{id}")];
|
||||
mapping.CheckDeliveries([SinkId], expectedMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class MessageMergerTests
|
||||
[Fact]
|
||||
public void Test_MessageMerger_AssemblesMessage()
|
||||
{
|
||||
DateTimeOffset creationTime = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset creationTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1));
|
||||
string responseId = Guid.NewGuid().ToString("N");
|
||||
string messageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
|
||||
+26
-16
@@ -13,26 +13,34 @@ description: >
|
||||
All commands run from the `python/` directory:
|
||||
|
||||
```bash
|
||||
# Format code (ruff format, parallel across packages)
|
||||
uv run poe fmt
|
||||
|
||||
# Lint and auto-fix (ruff check, parallel across packages)
|
||||
uv run poe lint
|
||||
# Syntax formatting + checks (parallel across packages by default)
|
||||
uv run poe syntax
|
||||
uv run poe syntax -P core
|
||||
uv run poe syntax -F # Format only
|
||||
uv run poe syntax -C # Check only
|
||||
uv run poe syntax -S # Samples only
|
||||
|
||||
# Type checking
|
||||
uv run poe pyright # Pyright (parallel across packages)
|
||||
uv run poe mypy # MyPy (parallel across packages)
|
||||
uv run poe pyright # Pyright fan-out across packages
|
||||
uv run poe pyright -P core
|
||||
uv run poe pyright -A
|
||||
uv run poe mypy # MyPy fan-out across packages
|
||||
uv run poe mypy -P core
|
||||
uv run poe mypy -A
|
||||
uv run poe typing # Both pyright and mypy
|
||||
uv run poe typing -P core
|
||||
uv run poe typing -A
|
||||
|
||||
# All package-level checks in parallel (fmt + lint + pyright + mypy)
|
||||
# All package-level checks in parallel (syntax + pyright)
|
||||
uv run poe check-packages
|
||||
|
||||
# Full check (packages + samples + tests + markdown)
|
||||
uv run poe check
|
||||
uv run poe check -P core
|
||||
|
||||
# Samples only
|
||||
uv run poe samples-lint # Ruff lint on samples/
|
||||
uv run poe samples-syntax # Pyright syntax check on samples/
|
||||
uv run poe check -S
|
||||
uv run poe pyright -S
|
||||
|
||||
# Markdown code blocks
|
||||
uv run poe markdown-code-lint
|
||||
@@ -40,8 +48,8 @@ uv run poe markdown-code-lint
|
||||
|
||||
## Pre-commit Hooks (prek)
|
||||
|
||||
Prek hooks run automatically on commit. They check only changed files and run
|
||||
package-level checks in parallel for affected packages only.
|
||||
Prek hooks run automatically on commit. They stay lightweight and only check
|
||||
changed files.
|
||||
|
||||
```bash
|
||||
# Install hooks
|
||||
@@ -54,8 +62,10 @@ uv run prek run -a
|
||||
uv run prek run --last-commit
|
||||
```
|
||||
|
||||
When core package changes, type-checking (mypy, pyright) runs across all packages
|
||||
since type changes propagate. Format and lint only run in changed packages.
|
||||
They run changed-package syntax formatting/checking, markdown code lint only
|
||||
when markdown files change, and sample syntax lint/pyright only when files
|
||||
under `samples/` change.
|
||||
They intentionally do not run workspace `pyright` or `mypy` by default.
|
||||
|
||||
## Ruff Configuration
|
||||
|
||||
@@ -80,6 +90,6 @@ in-process with streaming output.
|
||||
|
||||
CI splits into 4 parallel jobs:
|
||||
1. **Pre-commit hooks** — lightweight hooks (SKIP=poe-check)
|
||||
2. **Package checks** — fmt/lint/pyright via check-packages
|
||||
3. **Samples & markdown** — samples-lint, samples-syntax, markdown-code-lint
|
||||
2. **Package checks** — syntax/pyright via check-packages
|
||||
3. **Samples & markdown** — `check -S` plus `markdown-code-lint`
|
||||
4. **Mypy** — change-detected mypy checks
|
||||
|
||||
+4
-4
@@ -69,7 +69,7 @@ def equal(arg1: str, arg2: str) -> bool:
|
||||
|
||||
```python
|
||||
# Core
|
||||
from agent_framework import ChatAgent, Message, tool
|
||||
from agent_framework import Agent, Message, tool
|
||||
|
||||
# Components
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
@@ -82,16 +82,16 @@ from agent_framework.azure import AzureOpenAIChatClient
|
||||
## Public API and Exports
|
||||
|
||||
In `__init__.py` files that define package-level public APIs, use direct re-export imports plus an explicit
|
||||
`__all__`. Avoid identity aliases like `from ._agents import ChatAgent as ChatAgent`, and avoid
|
||||
`__all__`. Avoid identity aliases like `from ._agents import Agent as Agent`, and avoid
|
||||
`from module import *`.
|
||||
|
||||
Do not define `__all__` in internal non-`__init__.py` modules. Exception: modules intentionally exposed as a
|
||||
public import surface (for example, `agent_framework.observability`) should define `__all__`.
|
||||
|
||||
```python
|
||||
__all__ = ["ChatAgent", "Message", "ChatResponse"]
|
||||
__all__ = ["Agent", "Message", "ChatResponse"]
|
||||
|
||||
from ._agents import ChatAgent
|
||||
from ._agents import Agent
|
||||
from ._types import Message, ChatResponse
|
||||
```
|
||||
|
||||
|
||||
+43
-1
@@ -33,13 +33,44 @@ Uses [uv](https://github.com/astral-sh/uv) for dependency management and
|
||||
# Full setup (venv + install + prek hooks)
|
||||
uv run poe setup
|
||||
|
||||
# Install/update all dependencies
|
||||
# Install dependencies from lockfile (frozen resolution with prerelease policy)
|
||||
uv run poe install
|
||||
|
||||
# Create venv with specific Python version
|
||||
uv run poe venv --python 3.12
|
||||
|
||||
# Intentionally upgrade a specific dependency to reduce lockfile conflicts
|
||||
uv lock --upgrade-package <dependency-name> && uv run poe install
|
||||
|
||||
# Refresh all dev dependency pins, lockfile, and validation in one run
|
||||
uv run poe upgrade-dev-dependencies
|
||||
|
||||
# First, run workspace-wide lower/upper compatibility gates
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
|
||||
# Then expand bounds for one dependency in the target package
|
||||
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
|
||||
|
||||
# Repo-wide automation can reuse the same task
|
||||
uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
|
||||
# Add a dependency to one project and run both validators for that project/dependency
|
||||
uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"
|
||||
```
|
||||
|
||||
### Dependency Bound Notes
|
||||
|
||||
- Stable dependencies (`>=1.0`) should typically be bounded as `>=<known-good>,<next-major>`.
|
||||
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges).
|
||||
- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible.
|
||||
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
|
||||
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
|
||||
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
|
||||
- Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.
|
||||
|
||||
## Lazy Loading Pattern
|
||||
|
||||
Provider folders in core use `__getattr__` to lazy load from connector packages:
|
||||
@@ -74,6 +105,17 @@ def __getattr__(name: str) -> Any:
|
||||
4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml`
|
||||
5. Do **NOT** create lazy loading in core yet
|
||||
|
||||
Recommended dependency workflow during connector implementation:
|
||||
|
||||
1. Add the dependency to the target package:
|
||||
`uv run poe add-dependency-to-project --package core --dependency "<dependency-spec>"`
|
||||
2. Implement connector code and tests.
|
||||
3. Validate dependency bounds for that package/dependency:
|
||||
`uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"`
|
||||
4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command:
|
||||
`uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"`
|
||||
If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation.
|
||||
|
||||
### Promotion to Stable
|
||||
|
||||
1. Move samples to root `samples/` folder
|
||||
|
||||
+7
-4
@@ -41,11 +41,14 @@ Do **not** add sample-only dependencies to the root `pyproject.toml` dev group.
|
||||
## Syntax Checking
|
||||
|
||||
```bash
|
||||
# Check samples for syntax errors and missing imports
|
||||
uv run poe samples-syntax
|
||||
# Format + lint samples
|
||||
uv run poe syntax -S
|
||||
|
||||
# Lint samples
|
||||
uv run poe samples-lint
|
||||
# Check samples for syntax errors and missing imports
|
||||
uv run poe pyright -S
|
||||
|
||||
# Lint samples only
|
||||
uv run poe syntax -S -C
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+15
-8
@@ -17,20 +17,27 @@ We run tests in two stages, for a PR each commit is tested with unit tests only
|
||||
# Run tests for all packages in parallel
|
||||
uv run poe test
|
||||
|
||||
# Run tests for a specific package
|
||||
uv run --directory packages/core poe test
|
||||
# Run tests for a specific workspace package
|
||||
uv run poe test -P core
|
||||
|
||||
# Run all tests in a single pytest invocation (faster, uses pytest-xdist)
|
||||
uv run poe all-tests
|
||||
# Run all selected tests in a single pytest invocation
|
||||
uv run poe test -A
|
||||
|
||||
# With coverage
|
||||
uv run poe all-tests-cov
|
||||
uv run poe test -A -C
|
||||
uv run poe test -P core -C
|
||||
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
uv run poe test -A -m "not integration"
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
uv run poe test -A -m integration
|
||||
```
|
||||
|
||||
Direct package execution still works when you need it:
|
||||
|
||||
```bash
|
||||
uv run --directory packages/core poe test
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
@@ -38,7 +45,7 @@ uv run poe all-tests -m integration
|
||||
- **Async mode**: `asyncio_mode = "auto"` is enabled — do NOT use `@pytest.mark.asyncio`, but do mark tests with `async def` and use `await` for async calls
|
||||
- **Timeout**: Default 60 seconds per test
|
||||
- **Import mode**: `importlib` for cross-package isolation
|
||||
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The `all-tests` task also uses xdist across all packages.
|
||||
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The aggregate `uv run poe test -A` sweep also uses xdist across the selected packages.
|
||||
|
||||
## Test Directory Structure
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ repos:
|
||||
hooks:
|
||||
- id: poe-check
|
||||
name: Run checks through Poe
|
||||
entry: uv run poe prek-check
|
||||
entry: uv run python scripts/workspace_poe_tasks.py prek-check
|
||||
language: system
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.3
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
name: Bandit Security Checks
|
||||
@@ -63,7 +63,7 @@ repos:
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
# uv version.
|
||||
rev: 0.10.0
|
||||
rev: 0.10.10
|
||||
hooks:
|
||||
# Update the uv lockfile
|
||||
- id: uv-lock
|
||||
|
||||
Vendored
+49
-12
@@ -9,9 +9,8 @@
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"prek",
|
||||
"run",
|
||||
"-a"
|
||||
"poe",
|
||||
"check"
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -32,13 +31,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Format",
|
||||
"label": "Syntax",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"fmt",
|
||||
"syntax",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -59,13 +58,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Lint",
|
||||
"label": "Syntax (format only)",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"lint",
|
||||
"syntax",
|
||||
"-F",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
"fileLocation": [
|
||||
"relative",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"pattern": {
|
||||
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 4
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Syntax (check only)",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"syntax",
|
||||
"-C",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -169,7 +197,14 @@
|
||||
{
|
||||
"label": "Create Venv",
|
||||
"type": "shell",
|
||||
"command": "uv venv PYTHON=${input:py_version}",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"venv",
|
||||
"-P",
|
||||
"${input:py_version}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
@@ -184,7 +219,8 @@
|
||||
"run",
|
||||
"poe",
|
||||
"setup",
|
||||
"--python=${input:py_version}"
|
||||
"-P",
|
||||
"${input:py_version}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
@@ -200,11 +236,12 @@
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13"
|
||||
"3.13",
|
||||
"3.14"
|
||||
],
|
||||
"id": "py_version",
|
||||
"description": "Python version",
|
||||
"default": "3.10"
|
||||
"default": "3.13"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,12 @@ def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | Cha
|
||||
Avoid `**kwargs` unless absolutely necessary. It should only be used as an escape route, not for well-known flows of data:
|
||||
|
||||
- **Prefer named parameters**: If there are known extra arguments being passed, use explicit named parameters instead of kwargs
|
||||
- **Prefer purpose-specific buckets over generic kwargs**: If a flexible payload is still needed, use an explicit named parameter such as `additional_properties`, `function_invocation_kwargs`, or `client_kwargs` rather than a blanket `**kwargs`
|
||||
- **Subclassing support**: kwargs is acceptable in methods that are part of classes designed for subclassing, allowing subclass-defined kwargs to pass through without issues. In this case, clearly document that kwargs exists for subclass extensibility and not for passing arbitrary data
|
||||
- **Make known flows explicit first**: For abstract hooks, move known data flows into explicit parameters before leaving `**kwargs` behind for subclass extensibility (for example, prefer `state=` explicitly instead of passing it through kwargs)
|
||||
- **Prefer explicit metadata containers**: For constructors that expose metadata, prefer an explicit `additional_properties` parameter.
|
||||
- **Keep SDK passthroughs narrow and documented**: A kwargs escape hatch may be acceptable for provider helper APIs that pass through to a large or unstable external SDK surface, but it should be documented as SDK passthrough and revisited regularly
|
||||
- **Do not keep passthrough kwargs on wrappers that do not use them**: Convenience wrappers and session helpers should not accept generic kwargs merely to forward or ignore them
|
||||
- **Remove when possible**: In other cases, removing kwargs is likely better than keeping it
|
||||
- **Separate kwargs by purpose**: When combining kwargs for multiple purposes, use specific parameters like `client_kwargs: dict[str, Any]` instead of mixing everything in `**kwargs`
|
||||
- **Always document**: If kwargs must be used, always document how it's used, either by referencing external documentation or explaining its purpose
|
||||
@@ -160,10 +165,14 @@ user_msg = Message("user", ["Hello, world!"])
|
||||
asst_msg = Message("assistant", ["Hello, world!"])
|
||||
|
||||
# ❌ Not preferred - unnecessary inheritance
|
||||
from agent_framework import UserMessage, AssistantMessage
|
||||
class UserMessage(Message):
|
||||
pass
|
||||
|
||||
user_msg = UserMessage(content="Hello, world!")
|
||||
asst_msg = AssistantMessage(content="Hello, world!")
|
||||
class AssistantMessage(Message):
|
||||
pass
|
||||
|
||||
user_msg = UserMessage("user", ["Hello, world!"])
|
||||
asst_msg = AssistantMessage("assistant", ["Hello, world!"])
|
||||
```
|
||||
|
||||
### Import Structure
|
||||
@@ -383,6 +392,19 @@ All non-core packages declare a lower bound on `agent-framework-core` (e.g., `"a
|
||||
- **Core version changes**: When `agent-framework-core` is updated with breaking or significant changes and its version is bumped, update the `agent-framework-core>=...` lower bound in every other package's `pyproject.toml` to match the new core version.
|
||||
- **Non-core version changes**: Non-core packages (connectors, extensions) can have their own versions incremented independently while keeping the existing core lower bound pinned. Only raise the core lower bound if the non-core package actually depends on new core APIs.
|
||||
|
||||
### External Dependency Version Bounds
|
||||
|
||||
The guiding principle for external dependencies is to make the range of allowed versions as broad as possible, even if that means we have to do some conditional imports, and other tricks to allow small changes in versions.
|
||||
So we use bounded ranges for external package dependencies in `pyproject.toml`:
|
||||
|
||||
|
||||
- For stable dependencies (`>=1.0.0`), use a lower bound at a known-good version and an explicit upper bound that reflects the maximum major version we currently support (for example: `openai>=1.99.0,<3`).
|
||||
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good lower bound with a hard upper boundary in the same prerelease line (for example: `azure-ai-projects>=2.0.0b3,<2.0.0b4`).
|
||||
- For `<1.0.0` dependencies, use a known-good bounded range with an explicit upper cap. Prefer the broadest validated range the package can actually support: that may be a patch line, a minor line, or multiple minor lines (for example: `a2a-sdk>=0.3.5,<0.4.0`, `fastapi>=0.115.0,<0.136.0`, `uvicorn>=0.30.0,<0.39.0`).
|
||||
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good bounded range with a hard upper cap and keep the range only as broad as the package's validation coverage justifies.
|
||||
- Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility.
|
||||
- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --package <workspace-package-name> --dependency "<dependency-name>"` to expand package-scoped bounds.
|
||||
|
||||
### Installation Options
|
||||
|
||||
Connectors are distributed as separate packages and are not imported by default in the core package. Users install the specific connectors they need:
|
||||
|
||||
+135
-60
@@ -123,28 +123,39 @@ client = OpenAIChatClient(env_file_path="openai.env")
|
||||
|
||||
All the tests are located in the `tests` folder of each package. Tests marked with `@pytest.mark.integration` and `@skip_if_..._integration_tests_disabled` are integration tests that require external services (e.g., OpenAI, Azure OpenAI). They are automatically skipped when the required API keys or service endpoints are not configured in your environment or `.env` file.
|
||||
|
||||
You can select or exclude integration tests using pytest markers:
|
||||
The root `test` command now supports both project-scoped fan-out and a single aggregate sweep:
|
||||
|
||||
```bash
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
# Run package-local tests across all workspace packages
|
||||
uv run poe test
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
# Run tests for one workspace package
|
||||
uv run poe test -P core
|
||||
|
||||
# Run an aggregate pytest sweep across the selected packages
|
||||
uv run poe test -A
|
||||
|
||||
# Run only unit tests in aggregate mode
|
||||
uv run poe test -A -m "not integration"
|
||||
|
||||
# Run only integration tests in aggregate mode
|
||||
uv run poe test -A -m integration
|
||||
|
||||
# Run tests with coverage for one package or an aggregate sweep
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
Alternatively, you can run them using VSCode Tasks. Open the command palette
|
||||
(`Ctrl+Shift+P`) and type `Tasks: Run Task`. Select `Test` from the list.
|
||||
|
||||
If you want to run the tests for a single package, you can use the `uv run poe test` command with the package name as an argument. For example, to run the tests for the `agent_framework` package, you can use:
|
||||
Direct package execution still works when you need it:
|
||||
|
||||
```bash
|
||||
uv run poe --directory packages/core test
|
||||
```
|
||||
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The `all-tests` task also uses xdist across all packages.
|
||||
|
||||
These commands also output the coverage report.
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages.
|
||||
|
||||
## Code quality checks
|
||||
|
||||
@@ -158,10 +169,11 @@ Ideally you should run these checks before committing any changes, when you inst
|
||||
|
||||
## Code Coverage
|
||||
|
||||
We try to maintain a high code coverage for the project. To run the code coverage on the unit tests, you can use the following command:
|
||||
We try to maintain a high code coverage for the project. To review coverage locally, use either a package-scoped run or the aggregate sweep:
|
||||
|
||||
```bash
|
||||
uv run poe test
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
This will show you which files are not covered by the tests, including the specific lines not covered. Make sure to consider the untested lines from the code you are working on, but feel free to add other tests as well, that is always welcome!
|
||||
@@ -213,21 +225,24 @@ Set up the development environment with a virtual environment, install dependenc
|
||||
```bash
|
||||
uv run poe setup
|
||||
# or with specific Python version
|
||||
uv run poe setup --python 3.12
|
||||
uv run poe setup -P 3.12
|
||||
```
|
||||
|
||||
#### `install`
|
||||
Install all dependencies including extras and dev dependencies, including updates:
|
||||
Install all dependencies (including extras and dev dependencies) from the lockfile using frozen resolution:
|
||||
```bash
|
||||
uv run poe install
|
||||
```
|
||||
For intentional dependency upgrades, run `uv lock --upgrade-package <dependency-name>` and then run `uv run poe install`.
|
||||
|
||||
For repo-wide dev tooling refreshes, run `uv run poe upgrade-dev-dependencies` to repin dev dependencies, refresh `uv.lock`, and rerun validation, typing, and tests.
|
||||
|
||||
#### `venv`
|
||||
Create a virtual environment with specified Python version or switch python version:
|
||||
```bash
|
||||
uv run poe venv
|
||||
# or with specific Python version
|
||||
uv run poe venv --python 3.12
|
||||
uv run poe venv -P 3.12
|
||||
```
|
||||
|
||||
#### `prek-install`
|
||||
@@ -236,41 +251,89 @@ Install prek hooks:
|
||||
uv run poe prek-install
|
||||
```
|
||||
|
||||
### Code Quality and Formatting
|
||||
### Project-scoped command families
|
||||
|
||||
Each of the following tasks run against both the main `agent-framework` package and the extension packages in parallel, ensuring consistent code quality across the project.
|
||||
These commands default to `--package "*"`, so they run across all workspace packages unless you narrow them with `-P/--package`:
|
||||
|
||||
#### `fmt` (format)
|
||||
Format code using ruff (runs in parallel across all packages):
|
||||
#### `syntax`
|
||||
Run Ruff formatting plus Ruff lint checks by default:
|
||||
```bash
|
||||
uv run poe fmt
|
||||
uv run poe syntax
|
||||
uv run poe syntax -P core
|
||||
uv run poe syntax -F # format only
|
||||
uv run poe syntax -C # lint/check only
|
||||
```
|
||||
|
||||
#### `lint`
|
||||
Run linting checks and fix issues (runs in parallel across all packages):
|
||||
#### `build`
|
||||
Build workspace packages and the root meta package:
|
||||
```bash
|
||||
uv run poe lint
|
||||
uv run poe build
|
||||
uv run poe build -P core
|
||||
```
|
||||
|
||||
#### `clean-dist`
|
||||
Clean generated dist artifacts:
|
||||
```bash
|
||||
uv run poe clean-dist
|
||||
uv run poe clean-dist -P core
|
||||
```
|
||||
|
||||
### Dual-mode validation and test commands
|
||||
|
||||
These command families share the same selector model:
|
||||
|
||||
```bash
|
||||
uv run poe <command> # project fan-out over --package "*"
|
||||
uv run poe <command> -P core # one-project fan-out
|
||||
uv run poe <command> -A # aggregate sweep where supported
|
||||
```
|
||||
|
||||
#### `pyright`
|
||||
Run Pyright type checking (runs in parallel across all packages):
|
||||
Run Pyright type checking:
|
||||
```bash
|
||||
uv run poe pyright
|
||||
uv run poe pyright -P core
|
||||
uv run poe pyright -A
|
||||
```
|
||||
|
||||
#### `mypy`
|
||||
Run MyPy type checking (runs in parallel across all packages):
|
||||
Run MyPy type checking:
|
||||
```bash
|
||||
uv run poe mypy
|
||||
uv run poe mypy -P core
|
||||
uv run poe mypy -A
|
||||
```
|
||||
|
||||
#### `typing`
|
||||
Run both Pyright and MyPy type checking:
|
||||
Run both Pyright and MyPy:
|
||||
```bash
|
||||
uv run poe typing
|
||||
uv run poe typing -P core
|
||||
uv run poe typing -A
|
||||
```
|
||||
|
||||
### Code Validation
|
||||
#### `test`
|
||||
Run package-local tests in fan-out mode, or switch to one aggregate pytest sweep with `-A`:
|
||||
```bash
|
||||
uv run poe test
|
||||
uv run poe test -P core
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
### Sample-target variants
|
||||
|
||||
Use `-S/--samples` for sample-only validation instead of separate top-level commands:
|
||||
|
||||
```bash
|
||||
uv run poe syntax -S
|
||||
uv run poe syntax -S -C
|
||||
uv run poe pyright -S
|
||||
uv run poe check -S
|
||||
```
|
||||
|
||||
### Workspace validation and dependency commands
|
||||
|
||||
#### `markdown-code-lint`
|
||||
Lint markdown code blocks:
|
||||
@@ -278,72 +341,84 @@ Lint markdown code blocks:
|
||||
uv run poe markdown-code-lint
|
||||
```
|
||||
|
||||
### Comprehensive Checks
|
||||
|
||||
#### `check-packages`
|
||||
Run all package-level quality checks (format, lint, pyright, mypy) in parallel across all packages. This runs the full cross-product of (package Ă— check) concurrently:
|
||||
Run the package-level syntax sweep (`syntax`) plus `pyright` across the selected projects:
|
||||
```bash
|
||||
uv run poe check-packages
|
||||
uv run poe check-packages -P core
|
||||
```
|
||||
|
||||
#### `check`
|
||||
Run all quality checks including package checks, samples, tests and markdown lint:
|
||||
Run package syntax, pyright, and tests for the selected project set. Without `-P/--package`, it also includes sample checks and markdown lint:
|
||||
```bash
|
||||
uv run poe check
|
||||
uv run poe check -P core
|
||||
uv run poe check -S
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### `test`
|
||||
Run unit tests with coverage by invoking the `test` task in each package in parallel:
|
||||
#### `validate-dependency-bounds-test`
|
||||
Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure:
|
||||
```bash
|
||||
uv run poe test
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test -P core
|
||||
```
|
||||
|
||||
To run tests for a specific package only, use the `--directory` flag:
|
||||
#### `validate-dependency-bounds-project`
|
||||
Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`:
|
||||
```bash
|
||||
# Run tests for the core package
|
||||
uv run --directory packages/core poe test
|
||||
|
||||
# Run tests for the azure-ai package
|
||||
uv run --directory packages/azure-ai poe test
|
||||
uv run poe validate-dependency-bounds-project -M both -P core -D "<dependency-name>"
|
||||
```
|
||||
`--package` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --package "*"` to run the upper-bound pass across the workspace.
|
||||
For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may still be a single patch or minor line, but multi-minor ranges are fine when the package's checks/tests prove they work.
|
||||
|
||||
#### `all-tests`
|
||||
Run all tests in a single pytest invocation across all packages in parallel (excluding lab and devui). This is faster than `test` as it uses pytest's parallel execution:
|
||||
#### `add-dependency-and-validate-bounds`
|
||||
Add an external dependency to a workspace project and run both validators for that same project/dependency:
|
||||
```bash
|
||||
uv run poe all-tests
|
||||
uv run poe add-dependency-and-validate-bounds -P core -D "<dependency-spec>"
|
||||
```
|
||||
|
||||
#### `all-tests-cov`
|
||||
Same as `all-tests` but with coverage reporting enabled:
|
||||
#### `upgrade-dev-dependencies`
|
||||
Refresh exact dev dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests:
|
||||
```bash
|
||||
uv run poe all-tests-cov
|
||||
uv run poe upgrade-dev-dependencies
|
||||
```
|
||||
Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.
|
||||
|
||||
### Building and Publishing
|
||||
|
||||
#### `build`
|
||||
Build all packages:
|
||||
```bash
|
||||
uv run poe build
|
||||
```
|
||||
|
||||
#### `clean-dist`
|
||||
Clean the dist directories:
|
||||
```bash
|
||||
uv run poe clean-dist
|
||||
```
|
||||
|
||||
#### `publish`
|
||||
Publish packages to PyPI:
|
||||
```bash
|
||||
uv run poe publish
|
||||
```
|
||||
|
||||
### Compatibility aliases
|
||||
|
||||
These legacy commands still work during the transition, but prefer the newer forms above:
|
||||
|
||||
```bash
|
||||
uv run poe fmt # prefer: uv run poe syntax -F
|
||||
uv run poe format # prefer: uv run poe syntax -F
|
||||
uv run poe lint # prefer: uv run poe syntax -C
|
||||
uv run poe all-tests # prefer: uv run poe test -A
|
||||
uv run poe all-tests-cov # prefer: uv run poe test -A -C
|
||||
uv run poe samples-lint # prefer: uv run poe syntax -S -C
|
||||
uv run poe samples-syntax # prefer: uv run poe pyright -S
|
||||
```
|
||||
|
||||
## Prek Hooks
|
||||
|
||||
Prek hooks run automatically on commit and execute a subset of the checks on changed files only. Package-level checks (fmt, lint, pyright) run in parallel but only for packages with changed files. Markdown and sample checks are skipped when no relevant files were changed. If the `core` package is changed, all packages are checked. You can also run all checks using prek directly:
|
||||
Prek hooks run automatically on commit and stay intentionally lightweight:
|
||||
|
||||
- changed-package syntax formatting
|
||||
- changed-package syntax lint/check
|
||||
- markdown code lint only when markdown files change
|
||||
- sample lint + sample pyright only when files under `samples/` change
|
||||
|
||||
They do **not** run workspace `pyright` or `mypy` by default. Use `uv run poe pyright`, `uv run poe mypy`, `uv run poe typing`, `uv run poe check-packages`, or `uv run poe check` when you want deeper validation.
|
||||
|
||||
You can run the installed hooks directly with:
|
||||
|
||||
```bash
|
||||
uv run prek run -a
|
||||
|
||||
@@ -6,7 +6,7 @@ import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, TypeAlias, overload
|
||||
|
||||
import httpx
|
||||
@@ -35,10 +35,12 @@ from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
BaseHistoryProvider,
|
||||
Content,
|
||||
ContinuationToken,
|
||||
Message,
|
||||
ResponseStream,
|
||||
SessionContext,
|
||||
normalize_messages,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
@@ -226,6 +228,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
continuation_token: A2AContinuationToken | None = None,
|
||||
background: bool = False,
|
||||
**kwargs: Any,
|
||||
@@ -238,17 +242,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
continuation_token: A2AContinuationToken | None = None,
|
||||
background: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
def run( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
continuation_token: A2AContinuationToken | None = None,
|
||||
background: bool = False,
|
||||
**kwargs: Any,
|
||||
@@ -261,28 +269,53 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
Keyword Args:
|
||||
stream: Whether to stream the response. Defaults to False.
|
||||
session: The conversation session associated with the message(s).
|
||||
function_invocation_kwargs: Present for compatibility with the shared agent interface.
|
||||
A2AAgent does not use these values directly.
|
||||
client_kwargs: Present for compatibility with the shared agent interface.
|
||||
A2AAgent does not use these values directly.
|
||||
kwargs: Additional compatibility keyword arguments.
|
||||
A2AAgent does not use these values directly.
|
||||
continuation_token: Optional token to resume a long-running task
|
||||
instead of starting a new one.
|
||||
background: When True, in-progress task updates surface continuation
|
||||
tokens so the caller can poll or resubscribe later. When False
|
||||
(default), the agent internally waits for the task to complete.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
When stream=False: An Awaitable[AgentResponse].
|
||||
When stream=True: A ResponseStream of AgentResponseUpdate items.
|
||||
"""
|
||||
del function_invocation_kwargs, client_kwargs, kwargs
|
||||
normalized_messages = normalize_messages(messages)
|
||||
|
||||
if continuation_token is not None:
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
|
||||
TaskIdParams(id=continuation_token["task_id"])
|
||||
)
|
||||
else:
|
||||
normalized_messages = normalize_messages(messages)
|
||||
if not normalized_messages:
|
||||
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
|
||||
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
|
||||
a2a_stream = self.client.send_message(a2a_message)
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
session_context = SessionContext(
|
||||
session_id=provider_session.session_id if provider_session else None,
|
||||
service_session_id=provider_session.service_session_id if provider_session else None,
|
||||
input_messages=normalized_messages or [],
|
||||
options={},
|
||||
)
|
||||
|
||||
response = ResponseStream(
|
||||
self._map_a2a_stream(a2a_stream, background=background),
|
||||
self._map_a2a_stream(
|
||||
a2a_stream,
|
||||
background=background,
|
||||
session=provider_session,
|
||||
session_context=session_context,
|
||||
),
|
||||
finalizer=AgentResponse.from_updates,
|
||||
)
|
||||
if stream:
|
||||
@@ -294,6 +327,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
a2a_stream: AsyncIterable[A2AStreamItem],
|
||||
*,
|
||||
background: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
"""Map raw A2A protocol items to AgentResponseUpdates.
|
||||
|
||||
@@ -304,24 +339,52 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
background: When False, in-progress task updates are silently
|
||||
consumed (the stream keeps iterating until a terminal state).
|
||||
When True, they are yielded with a continuation token.
|
||||
session: The agent session for context providers.
|
||||
session_context: The session context for context providers.
|
||||
"""
|
||||
if session_context is None:
|
||||
session_context = SessionContext(input_messages=[], options={})
|
||||
|
||||
# Run before_run providers (forward order)
|
||||
for provider in self.context_providers:
|
||||
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
|
||||
continue
|
||||
if session is None:
|
||||
raise RuntimeError("Provider session must be available when context providers are configured.")
|
||||
await provider.before_run(
|
||||
agent=self, # type: ignore[arg-type]
|
||||
session=session,
|
||||
context=session_context,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
)
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
async for item in a2a_stream:
|
||||
if isinstance(item, A2AMessage):
|
||||
# Process A2A Message
|
||||
contents = self._parse_contents_from_a2a(item.parts)
|
||||
yield AgentResponseUpdate(
|
||||
update = AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if item.role == A2ARole.agent else "user",
|
||||
response_id=str(getattr(item, "message_id", uuid.uuid4())),
|
||||
raw_representation=item,
|
||||
)
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
|
||||
task, _update_event = item
|
||||
for update in self._updates_from_task(task, background=background):
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
raise NotImplementedError("Only Message and Task responses are supported")
|
||||
|
||||
# Set the response on the context for after_run providers
|
||||
if all_updates:
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=session, context=session_context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -474,13 +537,14 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
raise ValueError(f"Unknown content type: {content.type}")
|
||||
|
||||
# Exclude framework-internal keys (e.g. attribution) from wire metadata
|
||||
internal_keys = {"_attribution"}
|
||||
internal_keys = {"_attribution", "context_id"}
|
||||
metadata = {k: v for k, v in message.additional_properties.items() if k not in internal_keys} or None
|
||||
|
||||
return A2AMessage(
|
||||
role=A2ARole("user"),
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
context_id=message.additional_properties.get("context_id"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"a2a-sdk>=0.3.5",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -23,11 +23,14 @@ from a2a.types import Role as A2ARole
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseContextProvider,
|
||||
Content,
|
||||
Message,
|
||||
SessionContext,
|
||||
)
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, raises
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
@@ -507,6 +510,23 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
"""Test conversion of Message preserves context_id without duplicating it in metadata."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), _http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Continue the task")],
|
||||
additional_properties={"context_id": "ctx-123", "trace_id": "trace-456"},
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message)
|
||||
|
||||
assert result.context_id == "ctx-123"
|
||||
assert result.metadata == {"trace_id": "trace-456"}
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
"""Test conversion of A2A DataPart."""
|
||||
|
||||
@@ -834,3 +854,188 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Context Provider Tests
|
||||
|
||||
|
||||
class TrackingContextProvider(BaseContextProvider):
|
||||
"""A context provider that records when before_run and after_run are called."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tracking-provider")
|
||||
self.before_run_called = False
|
||||
self.after_run_called = False
|
||||
self.before_run_context: SessionContext | None = None
|
||||
self.after_run_context: SessionContext | None = None
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
self.before_run_called = True
|
||||
self.before_run_context = context
|
||||
|
||||
async def after_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
self.after_run_called = True
|
||||
self.after_run_context = context
|
||||
|
||||
|
||||
async def test_run_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context providers are invoked during non-streaming run."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello from A2A")
|
||||
session = agent.create_session()
|
||||
|
||||
response = await agent.run("Hello", session=session)
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
assert response.text == "Hello from A2A"
|
||||
|
||||
|
||||
async def test_run_streaming_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context providers are invoked during streaming run."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Streamed response")
|
||||
session = agent.create_session()
|
||||
|
||||
stream = agent.run("Hello", stream=True, session=session)
|
||||
updates = []
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "Streamed response"
|
||||
|
||||
|
||||
async def test_context_providers_receive_response(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that after_run providers can access the response via session context."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Response text")
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert provider.after_run_context is not None
|
||||
assert provider.after_run_context.response is not None
|
||||
assert provider.after_run_context.response.text == "Response text"
|
||||
|
||||
|
||||
async def test_context_providers_receive_input_messages(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that before_run providers can access input messages via session context."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Reply")
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello world", session=session)
|
||||
|
||||
assert provider.before_run_context is not None
|
||||
assert len(provider.before_run_context.input_messages) > 0
|
||||
assert provider.before_run_context.input_messages[-1].text == "Hello world"
|
||||
|
||||
|
||||
async def test_run_without_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that run works normally when no context providers are configured."""
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello")
|
||||
|
||||
response = await agent.run("Hello")
|
||||
|
||||
assert response.text == "Hello"
|
||||
|
||||
|
||||
async def test_run_creates_session_for_providers_when_none_provided(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a session is auto-created when context providers are configured but no session is passed."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello")
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
|
||||
|
||||
@mark.parametrize("messages", [None, []])
|
||||
async def test_run_raises_when_no_messages_and_no_continuation_token(
|
||||
mock_a2a_client: MockA2AClient, messages: list[str] | None
|
||||
) -> None:
|
||||
"""Test that run() raises ValueError when messages is None/empty and no continuation_token is provided."""
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
|
||||
with raises(ValueError, match="At least one message is required"):
|
||||
await agent.run(messages)
|
||||
|
||||
|
||||
async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that run() does not raise when messages is None but a continuation_token is provided."""
|
||||
task = Task(
|
||||
id="task-cont",
|
||||
context_id="ctx-cont",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
)
|
||||
mock_a2a_client.resubscribe_responses.append((task, None))
|
||||
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
|
||||
token = A2AContinuationToken(task_id="task-cont", context_id="ctx-cont")
|
||||
response = await agent.run(None, continuation_token=token)
|
||||
assert response is not None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1015,7 +1015,7 @@ async def run_agent_stream(
|
||||
flow.tool_calls_by_id[confirm_id] = confirm_entry
|
||||
flow.tool_calls_ended.add(confirm_id) # Mark as ended since we emit End event
|
||||
flow.waiting_for_approval = True
|
||||
flow.interrupts = [
|
||||
flow.interrupts.append(
|
||||
{
|
||||
"id": str(confirm_id),
|
||||
"value": {
|
||||
@@ -1027,7 +1027,7 @@ async def run_agent_stream(
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# Close any open message
|
||||
if flow.message_id:
|
||||
|
||||
@@ -220,7 +220,6 @@ class AGUIChatClient(
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the AG-UI chat client.
|
||||
|
||||
@@ -231,13 +230,11 @@ class AGUIChatClient(
|
||||
additional_properties: Additional properties to store
|
||||
middleware: Optional middleware to apply to the client.
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
**kwargs: Additional arguments passed to BaseChatClient
|
||||
"""
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
self._http_service = AGUIHttpService(
|
||||
endpoint=endpoint,
|
||||
|
||||
@@ -242,8 +242,16 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
# Use message_id for deduplication when available — two messages with the
|
||||
# same id are definitively the same message (e.g. upstream replays), while
|
||||
# different messages that happen to share identical content (e.g. repeated
|
||||
# "yes" confirmations) will have distinct ids and be preserved.
|
||||
# Fall back to content-hash when message_id is absent or empty.
|
||||
if msg.message_id:
|
||||
key = ("id", msg.message_id)
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = ("content", role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework import BaseChatClient
|
||||
from agent_framework._tools import _append_unique_tools # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import SupportsAgentRun
|
||||
@@ -22,7 +23,7 @@ def _collect_mcp_tool_functions(mcp_tools: list[Any]) -> list[Any]:
|
||||
mcp_tools: List of MCP tool instances.
|
||||
|
||||
Returns:
|
||||
List of functions from connected MCP tools.
|
||||
Functions from connected MCP tools.
|
||||
"""
|
||||
functions: list[Any] = []
|
||||
for mcp_tool in mcp_tools:
|
||||
@@ -56,7 +57,11 @@ def collect_server_tools(agent: SupportsAgentRun) -> list[Any]:
|
||||
# Include functions from connected MCP tools (only available on Agent)
|
||||
mcp_tools = getattr(agent, "mcp_tools", None)
|
||||
if mcp_tools:
|
||||
server_tools.extend(_collect_mcp_tool_functions(mcp_tools))
|
||||
_append_unique_tools(
|
||||
server_tools,
|
||||
_collect_mcp_tool_functions(mcp_tools),
|
||||
duplicate_error_message="Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool.",
|
||||
)
|
||||
|
||||
logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools")
|
||||
for tool in server_tools:
|
||||
@@ -109,26 +114,13 @@ def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list
|
||||
logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)")
|
||||
return None
|
||||
|
||||
server_tool_names = {getattr(tool, "name", None) for tool in server_tools}
|
||||
unique_client_tools = [tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names]
|
||||
|
||||
if not unique_client_tools:
|
||||
# Same check: must pass server tools if any require approval
|
||||
if server_tools and _has_approval_tools(server_tools):
|
||||
logger.info(
|
||||
f"[TOOLS] Client tools duplicate server but server has approval tools - "
|
||||
f"passing {len(server_tools)} server tools for approval mode"
|
||||
)
|
||||
return server_tools
|
||||
logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter")
|
||||
return None
|
||||
|
||||
combined_tools: list[Any] = []
|
||||
if server_tools:
|
||||
combined_tools.extend(server_tools)
|
||||
combined_tools.extend(unique_client_tools)
|
||||
combined_tools = _append_unique_tools(
|
||||
list(server_tools),
|
||||
client_tools,
|
||||
duplicate_error_message="Tool names must be unique.",
|
||||
)
|
||||
logger.info(
|
||||
f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools "
|
||||
f"({len(server_tools)} server + {len(unique_client_tools)} unique client)"
|
||||
f"({len(server_tools)} server + {len(client_tools)} client)"
|
||||
)
|
||||
return combined_tools
|
||||
|
||||
@@ -320,7 +320,7 @@ def _emit_approval_request(
|
||||
)
|
||||
interrupt_id = func_call_id or content.id
|
||||
if interrupt_id:
|
||||
flow.interrupts = [
|
||||
flow.interrupts.append(
|
||||
{
|
||||
"id": str(interrupt_id),
|
||||
"value": {
|
||||
@@ -332,7 +332,7 @@ def _emit_approval_request(
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if require_confirmation:
|
||||
confirm_id = generate_event_id()
|
||||
|
||||
@@ -6,13 +6,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import uvicorn
|
||||
from agent_framework import ChatOptions
|
||||
from agent_framework._clients import SupportsChatGetResponse
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework.anthropic import AnthropicClient
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -26,6 +25,15 @@ from ..agents.task_steps_agent import task_steps_agent_wrapped
|
||||
from ..agents.ui_generator_agent import ui_generator_agent
|
||||
from ..agents.weather_agent import weather_agent
|
||||
|
||||
AnthropicClient: type[Any] | None
|
||||
try:
|
||||
import agent_framework.anthropic as _anthropic_namespace
|
||||
except ImportError:
|
||||
# If the Anthropic client isn't installed, we can still run the server with Azure OpenAI as the default chat client
|
||||
AnthropicClient = None
|
||||
else:
|
||||
AnthropicClient = cast(type[Any] | None, getattr(_anthropic_namespace, "AnthropicClient", None))
|
||||
|
||||
# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable)
|
||||
if os.getenv("ENABLE_DEBUG_LOGGING"):
|
||||
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log")
|
||||
@@ -70,7 +78,9 @@ app.add_middleware(
|
||||
# Set CHAT_CLIENT=anthropic to use Anthropic, defaults to Azure OpenAI
|
||||
client: SupportsChatGetResponse[ChatOptions] = cast(
|
||||
SupportsChatGetResponse[ChatOptions],
|
||||
AnthropicClient() if os.getenv("CHAT_CLIENT", "").lower() == "anthropic" else AzureOpenAIChatClient(),
|
||||
AnthropicClient()
|
||||
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
|
||||
else AzureOpenAIChatClient(),
|
||||
)
|
||||
|
||||
# Agentic Chat - basic chat agent
|
||||
|
||||
@@ -23,15 +23,15 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"ag-ui-protocol>=0.1.9",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn>=0.30.0"
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"pytest==9.0.2",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -72,6 +72,10 @@ typeCheckingMode = "basic"
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui'
|
||||
|
||||
@@ -98,7 +98,11 @@ class StreamingChatClientStub(
|
||||
options: OptionsCoT | ChatOptions[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
|
||||
self.last_session = kwargs.get("session")
|
||||
client_kwargs = kwargs.get("client_kwargs")
|
||||
if isinstance(client_kwargs, Mapping):
|
||||
self.last_session = cast(AgentSession | None, client_kwargs.get("session"))
|
||||
else:
|
||||
self.last_session = None
|
||||
self.last_service_session_id = self.last_session.service_session_id if self.last_session else None
|
||||
return cast(
|
||||
Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
|
||||
|
||||
@@ -702,14 +702,9 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub
|
||||
"""Test that when use_service_session is True, the AgentSession used to run the agent is set to the service session ID."""
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
request_service_session_id: str | None = None
|
||||
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
nonlocal request_service_session_id
|
||||
session = kwargs.get("session")
|
||||
request_service_session_id = session.service_session_id if session else None
|
||||
yield ChatResponseUpdate(
|
||||
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
|
||||
)
|
||||
@@ -719,11 +714,22 @@ async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub
|
||||
|
||||
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
|
||||
|
||||
# Spy on agent.run to capture the session kwarg at call time (before streaming mutates it)
|
||||
captured_service_session_id: str | None = None
|
||||
original_run = agent.run
|
||||
|
||||
def capturing_run(*args: Any, **kwargs: Any) -> Any:
|
||||
nonlocal captured_service_session_id
|
||||
session = kwargs.get("session")
|
||||
captured_service_session_id = session.service_session_id if session else None
|
||||
return original_run(*args, **kwargs)
|
||||
|
||||
agent.run = capturing_run # type: ignore[assignment, method-assign]
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in wrapper.run(input_data):
|
||||
events.append(event)
|
||||
request_service_session_id = agent.client.last_service_session_id
|
||||
assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set)
|
||||
assert captured_service_session_id == "conv_123456"
|
||||
|
||||
|
||||
async def test_function_approval_mode_executes_tool(streaming_chat_client_stub):
|
||||
|
||||
@@ -1015,15 +1015,111 @@ def test_deduplicate_assistant_tool_calls():
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_deduplicate_general_messages():
|
||||
"""Duplicate general user messages are deduplicated."""
|
||||
def test_deduplicate_by_message_id():
|
||||
"""Messages with the same message_id are deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg1.message_id = "msg-1"
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg2.message_id = "msg-1"
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
assert result == [msg1]
|
||||
|
||||
|
||||
def test_deduplicate_preserves_repeated_confirmations_with_distinct_ids():
|
||||
"""Identical content with different message_ids is preserved."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
assistant = Message(role="assistant", contents=[Content.from_text(text="Are you sure?")])
|
||||
assistant.message_id = "msg-1"
|
||||
confirm1 = Message(role="user", contents=[Content.from_text(text="yes")])
|
||||
confirm1.message_id = "msg-2"
|
||||
confirm2 = Message(role="user", contents=[Content.from_text(text="yes")])
|
||||
confirm2.message_id = "msg-3"
|
||||
|
||||
result = _deduplicate_messages([confirm1, assistant, confirm2])
|
||||
assert result == [confirm1, assistant, confirm2]
|
||||
|
||||
|
||||
def test_deduplicate_preserves_repeated_system_messages_with_distinct_ids():
|
||||
"""Non-consecutive identical system messages with different ids are preserved."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
sys1 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
|
||||
sys1.message_id = "msg-1"
|
||||
user_msg = Message(role="user", contents=[Content.from_text(text="Hi")])
|
||||
user_msg.message_id = "msg-2"
|
||||
sys2 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
|
||||
sys2.message_id = "msg-3"
|
||||
|
||||
result = _deduplicate_messages([sys1, user_msg, sys2])
|
||||
assert result == [sys1, user_msg, sys2]
|
||||
|
||||
|
||||
def test_deduplicate_skips_replayed_system_messages_with_same_id():
|
||||
"""System messages replayed with the same message_id are deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msgs = []
|
||||
for _ in range(3):
|
||||
m = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
|
||||
m.message_id = "msg-1"
|
||||
msgs.append(m)
|
||||
|
||||
result = _deduplicate_messages(msgs)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_deduplicate_without_message_id_uses_content_hash():
|
||||
"""Messages without message_id are deduplicated by content hash."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
assert result == [msg1]
|
||||
|
||||
|
||||
def test_deduplicate_without_message_id_preserves_different_content():
|
||||
"""Messages without message_id but different content are preserved."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert result == [msg1, msg2]
|
||||
|
||||
|
||||
def test_deduplicate_handles_none_contents():
|
||||
"""Messages with contents=None pass through without errors; duplicates are deduped."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=None)
|
||||
msg2 = Message(role="assistant", contents=[Content.from_text(text="Hello")])
|
||||
msg3 = Message(role="user", contents=None)
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2, msg3])
|
||||
assert result == [msg1, msg2]
|
||||
|
||||
|
||||
def test_deduplicate_mixed_id_and_no_id():
|
||||
"""Messages with and without message_id coexist correctly."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg1.message_id = "msg-1"
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) # no id
|
||||
msg3 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg3.message_id = "msg-1" # duplicate of msg1
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2, msg3])
|
||||
assert len(result) == 2
|
||||
assert result == [msg1, msg2]
|
||||
|
||||
|
||||
def test_deduplicate_replaces_empty_tool_result():
|
||||
@@ -1038,7 +1134,30 @@ def test_deduplicate_replaces_empty_tool_result():
|
||||
assert result[0].contents[0].result == "actual result"
|
||||
|
||||
|
||||
# ── Multimodal & content conversion edge cases ──
|
||||
def test_deduplicate_empty_string_message_id_falls_back_to_content_hash():
|
||||
"""Empty-string message_id is treated as missing; content-hash dedup is used."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg1.message_id = ""
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
|
||||
msg2.message_id = ""
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert result == [msg1, msg2], "Different content with empty IDs should both be preserved"
|
||||
|
||||
|
||||
def test_deduplicate_empty_string_message_id_deduplicates_same_content():
|
||||
"""Empty-string message_id with identical content should be deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg1.message_id = ""
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg2.message_id = ""
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert result == [msg1], "Same content with empty IDs should be deduplicated"
|
||||
|
||||
|
||||
def test_convert_agui_content_unknown_source_type_fallback():
|
||||
|
||||
@@ -538,6 +538,27 @@ def test_emit_approval_request_populates_interrupt_metadata():
|
||||
assert flow.interrupts[0]["value"]["type"] == "function_approval_request"
|
||||
|
||||
|
||||
def test_emit_approval_request_accumulates_multiple_interrupts():
|
||||
"""Multiple approval requests in the same turn should accumulate in flow.interrupts."""
|
||||
flow = FlowState(message_id="msg-1")
|
||||
|
||||
for i in range(1, 4):
|
||||
function_call = Content.from_function_call(
|
||||
call_id=f"call_{i}",
|
||||
name=f"tool_{i}",
|
||||
arguments={"arg": f"value_{i}"},
|
||||
)
|
||||
approval_content = Content.from_function_approval_request(
|
||||
id=f"approval_{i}",
|
||||
function_call=function_call,
|
||||
)
|
||||
_emit_approval_request(approval_content, flow)
|
||||
|
||||
assert len(flow.interrupts) == 3
|
||||
interrupt_ids = {intr["id"] for intr in flow.interrupts}
|
||||
assert interrupt_ids == {"call_1", "call_2", "call_3"}
|
||||
|
||||
|
||||
def test_resume_to_tool_messages_from_interrupts_payload():
|
||||
"""Resume payload interrupt responses map to tool messages."""
|
||||
resume = {
|
||||
@@ -874,6 +895,81 @@ class TestTextMessageEventBalancing:
|
||||
assert len(end_events) == 2
|
||||
|
||||
|
||||
async def test_run_agent_stream_accumulates_multiple_confirm_interrupts():
|
||||
"""Multiple predictive tool calls in a single streaming run should accumulate interrupts.
|
||||
|
||||
This exercises the confirm_changes path in run_agent_stream (_agent_run.py),
|
||||
ensuring that flow.interrupts.append() works correctly for multiple tool calls
|
||||
and all interrupts appear in the RUN_FINISHED event.
|
||||
"""
|
||||
import json
|
||||
|
||||
from conftest import StubAgent
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
predict_config = {
|
||||
"tasks": {"tool": "generate_tasks", "tool_argument": "steps"},
|
||||
"notes": {"tool": "generate_notes", "tool_argument": "items"},
|
||||
}
|
||||
state_schema = {
|
||||
"tasks": {"type": "array", "items": {"type": "object"}},
|
||||
"notes": {"type": "array", "items": {"type": "object"}},
|
||||
}
|
||||
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="generate_tasks",
|
||||
call_id="call-tasks",
|
||||
arguments=json.dumps({"steps": [{"description": "Task 1"}]}),
|
||||
),
|
||||
Content.from_function_call(
|
||||
name="generate_notes",
|
||||
call_id="call-notes",
|
||||
arguments=json.dumps({"items": [{"description": "Note 1"}]}),
|
||||
),
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
|
||||
stub = StubAgent(updates=updates)
|
||||
agent = AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema=state_schema,
|
||||
predict_state_config=predict_config,
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"thread_id": "thread-multi",
|
||||
"run_id": "run-multi",
|
||||
"messages": [{"role": "user", "content": "Generate tasks and notes"}],
|
||||
"state": {"tasks": [], "notes": []},
|
||||
}
|
||||
|
||||
events = [event async for event in agent.run(payload)]
|
||||
|
||||
# Find RUN_FINISHED event and verify multiple interrupts
|
||||
finished_events = [
|
||||
e
|
||||
for e in events
|
||||
if getattr(e, "type", None) == "RUN_FINISHED"
|
||||
or getattr(getattr(e, "type", None), "value", None) == "RUN_FINISHED"
|
||||
]
|
||||
assert finished_events, f"Expected RUN_FINISHED event. Types: {[getattr(e, 'type', None) for e in events]}"
|
||||
finished = finished_events[-1]
|
||||
interrupt = getattr(finished, "interrupt", None)
|
||||
assert interrupt is not None, "Expected interrupt metadata in RUN_FINISHED"
|
||||
assert len(interrupt) == 2, f"Expected 2 interrupts (one per tool), got {len(interrupt)}"
|
||||
|
||||
# Verify both tool calls are represented in interrupt metadata
|
||||
interrupt_tool_names = {i["value"]["function_call"]["name"] for i in interrupt}
|
||||
assert interrupt_tool_names == {"generate_tasks", "generate_notes"}
|
||||
|
||||
|
||||
def test_emit_oauth_consent_request():
|
||||
"""Test that oauth_consent_request content emits a CustomEvent."""
|
||||
content = Content.from_oauth_consent_request(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, tool
|
||||
|
||||
from agent_framework_ag_ui._orchestration._tooling import (
|
||||
@@ -20,7 +21,8 @@ class DummyTool:
|
||||
class MockMCPTool:
|
||||
"""Mock MCP tool that simulates connected MCP tool with functions."""
|
||||
|
||||
def __init__(self, functions: list[DummyTool], is_connected: bool = True) -> None:
|
||||
def __init__(self, functions: list[DummyTool], is_connected: bool = True, name: str = "mock-mcp") -> None:
|
||||
self.name = name
|
||||
self.functions = functions
|
||||
self.is_connected = is_connected
|
||||
|
||||
@@ -45,11 +47,8 @@ def test_merge_tools_filters_duplicates() -> None:
|
||||
server = [DummyTool("a"), DummyTool("b")]
|
||||
client = [DummyTool("b"), DummyTool("c")]
|
||||
|
||||
merged = merge_tools(server, client)
|
||||
|
||||
assert merged is not None
|
||||
names = [getattr(t, "name", None) for t in merged]
|
||||
assert names == ["a", "b", "c"]
|
||||
with pytest.raises(ValueError, match="Duplicate tool name 'b'"):
|
||||
merge_tools(server, client)
|
||||
|
||||
|
||||
def test_register_additional_client_tools_assigns_when_configured() -> None:
|
||||
@@ -131,6 +130,17 @@ def test_collect_server_tools_with_mcp_tools_via_public_property() -> None:
|
||||
assert len(tools) == 2
|
||||
|
||||
|
||||
def test_collect_server_tools_raises_on_duplicate_agent_and_mcp_tool_names() -> None:
|
||||
duplicate_tool = DummyTool("regular_tool")
|
||||
mock_mcp = MockMCPTool([duplicate_tool], is_connected=True, name="docs-mcp")
|
||||
|
||||
agent = _create_chat_agent_with_tool("regular_tool")
|
||||
agent.mcp_tools = [mock_mcp]
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate tool name 'regular_tool'"):
|
||||
collect_server_tools(agent)
|
||||
|
||||
|
||||
# Additional tests for tooling coverage
|
||||
|
||||
|
||||
@@ -176,11 +186,11 @@ def test_merge_tools_no_client_tools() -> None:
|
||||
|
||||
|
||||
def test_merge_tools_all_duplicates() -> None:
|
||||
"""merge_tools returns None when all client tools duplicate server tools."""
|
||||
"""merge_tools raises when client and server tools share a name."""
|
||||
server = [DummyTool("a"), DummyTool("b")]
|
||||
client = [DummyTool("a"), DummyTool("b")]
|
||||
result = merge_tools(server, client)
|
||||
assert result is None
|
||||
with pytest.raises(ValueError, match="Duplicate tool name 'a'"):
|
||||
merge_tools(server, client)
|
||||
|
||||
|
||||
def test_merge_tools_empty_server() -> None:
|
||||
@@ -208,7 +218,7 @@ def test_merge_tools_with_approval_tools_no_client() -> None:
|
||||
|
||||
|
||||
def test_merge_tools_with_approval_tools_all_duplicates() -> None:
|
||||
"""merge_tools returns server tools with approval mode even when client duplicates."""
|
||||
"""merge_tools raises even when a client tool duplicates an approval-gated server tool."""
|
||||
|
||||
class ApprovalTool:
|
||||
def __init__(self, name: str):
|
||||
@@ -217,7 +227,5 @@ def test_merge_tools_with_approval_tools_all_duplicates() -> None:
|
||||
|
||||
server = [ApprovalTool("write_doc")]
|
||||
client = [DummyTool("write_doc")] # Same name as server
|
||||
result = merge_tools(server, client)
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].approval_mode == "always_require"
|
||||
with pytest.raises(ValueError, match="Duplicate tool name 'write_doc'"):
|
||||
merge_tools(server, client)
|
||||
|
||||
@@ -228,11 +228,11 @@ class AnthropicClient(
|
||||
model_id: str | None = None,
|
||||
anthropic_client: AsyncAnthropic | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Anthropic Agent client.
|
||||
|
||||
@@ -244,11 +244,11 @@ class AnthropicClient(
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
additional_beta_flags: Additional beta flags to enable on the client.
|
||||
Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25".
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional middleware to apply to the client.
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -319,9 +319,9 @@ class AnthropicClient(
|
||||
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"anthropic>=0.70.0,<1",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-search-documents==11.7.0b2",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -87,9 +87,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -17,10 +17,15 @@ from agent_framework_azure_ai_search._context_provider import AzureAISearchConte
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_azure_search_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith("AZURE_SEARCH_"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
def clear_azure_search_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep tests isolated from ambient Azure Search environment variables."""
|
||||
for key in (
|
||||
"AZURE_SEARCH_ENDPOINT",
|
||||
"AZURE_SEARCH_INDEX_NAME",
|
||||
"AZURE_SEARCH_KNOWLEDGE_BASE_NAME",
|
||||
"AZURE_SEARCH_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
class MockSearchResults:
|
||||
|
||||
@@ -11,6 +11,11 @@ from ._embedding_client import (
|
||||
AzureAIInferenceEmbeddingSettings,
|
||||
RawAzureAIInferenceEmbeddingClient,
|
||||
)
|
||||
from ._foundry_evals import (
|
||||
FoundryEvals,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._foundry_memory_provider import FoundryMemoryProvider
|
||||
from ._project_provider import AzureAIProjectAgentProvider
|
||||
from ._shared import AzureAISettings
|
||||
@@ -31,8 +36,11 @@ __all__ = [
|
||||
"AzureAIProjectAgentOptions",
|
||||
"AzureAIProjectAgentProvider",
|
||||
"AzureAISettings",
|
||||
"FoundryEvals",
|
||||
"FoundryMemoryProvider",
|
||||
"RawAzureAIClient",
|
||||
"RawAzureAIInferenceEmbeddingClient",
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
]
|
||||
|
||||
@@ -444,11 +444,11 @@ class AzureAIAgentClient(
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
should_cleanup_agent: bool = True,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Azure AI Agent client.
|
||||
|
||||
@@ -471,11 +471,11 @@ class AzureAIAgentClient(
|
||||
should_cleanup_agent: Whether to cleanup (delete) agents created by this client when
|
||||
the client is closed or context is exited. Defaults to True. Only affects agents
|
||||
created by this client instance; existing agents passed via agent_id are never deleted.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional sequence of middlewares to include.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -548,9 +548,9 @@ class AzureAIAgentClient(
|
||||
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
|
||||
@@ -119,9 +119,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a bare Azure AI client.
|
||||
|
||||
@@ -145,9 +145,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -217,7 +217,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
@@ -1243,11 +1243,11 @@ class AzureAIClient(
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Azure AI client with full layer support.
|
||||
|
||||
@@ -1268,11 +1268,11 @@ class AzureAIClient(
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional sequence of chat middlewares to include.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -1319,9 +1319,9 @@ class AzureAIClient(
|
||||
credential=credential,
|
||||
use_latest_version=use_latest_version,
|
||||
allow_preview=allow_preview,
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -124,9 +124,9 @@ class RawAzureAIInferenceEmbeddingClient(
|
||||
text_client: EmbeddingsClient | None = None,
|
||||
image_client: ImageEmbeddingsClient | None = None,
|
||||
credential: AzureKeyCredential | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a raw Azure AI Inference embedding client."""
|
||||
settings = load_settings(
|
||||
@@ -160,7 +160,7 @@ class RawAzureAIInferenceEmbeddingClient(
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
)
|
||||
self._endpoint = resolved_endpoint
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(additional_properties=additional_properties)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying SDK clients and release resources."""
|
||||
@@ -376,9 +376,9 @@ class AzureAIInferenceEmbeddingClient(
|
||||
image_client: ImageEmbeddingsClient | None = None,
|
||||
credential: AzureKeyCredential | None = None,
|
||||
otel_provider_name: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Azure AI Inference embedding client."""
|
||||
super().__init__(
|
||||
@@ -389,8 +389,8 @@ class AzureAIInferenceEmbeddingClient(
|
||||
text_client=text_client,
|
||||
image_client=image_client,
|
||||
credential=credential,
|
||||
additional_properties=additional_properties,
|
||||
otel_provider_name=otel_provider_name,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Microsoft Foundry Evals integration for Microsoft Agent Framework.
|
||||
|
||||
Provides ``FoundryEvals``, an ``Evaluator`` implementation backed by Azure AI
|
||||
Foundry's built-in evaluators. See docs/decisions/0018-foundry-evals-integration.md
|
||||
for the design rationale.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from agent_framework import evaluate_agent
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment="gpt-4o")
|
||||
results = await evaluate_agent(
|
||||
agent=my_agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=evals,
|
||||
)
|
||||
assert results.all_passed
|
||||
print(results.report_url)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Sequence, cast
|
||||
|
||||
from agent_framework._evaluation import (
|
||||
ConversationSplit,
|
||||
ConversationSplitter,
|
||||
EvalItem,
|
||||
EvalItemResult,
|
||||
EvalResults,
|
||||
EvalScoreResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Agent evaluators that accept query/response as conversation arrays.
|
||||
# Maintained manually — check https://learn.microsoft.com/en-us/azure/ai-studio/how-to/develop/evaluate-sdk
|
||||
# for the latest evaluator list. These are the evaluators that need conversation-format input.
|
||||
_AGENT_EVALUATORS: set[str] = {
|
||||
"builtin.intent_resolution",
|
||||
"builtin.task_adherence",
|
||||
"builtin.task_completion",
|
||||
"builtin.task_navigation_efficiency",
|
||||
"builtin.tool_call_accuracy",
|
||||
"builtin.tool_selection",
|
||||
"builtin.tool_input_accuracy",
|
||||
"builtin.tool_output_utilization",
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
# Evaluators that additionally require tool_definitions.
|
||||
_TOOL_EVALUATORS: set[str] = {
|
||||
"builtin.tool_call_accuracy",
|
||||
"builtin.tool_selection",
|
||||
"builtin.tool_input_accuracy",
|
||||
"builtin.tool_output_utilization",
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
_BUILTIN_EVALUATORS: dict[str, str] = {
|
||||
# Agent behavior
|
||||
"intent_resolution": "builtin.intent_resolution",
|
||||
"task_adherence": "builtin.task_adherence",
|
||||
"task_completion": "builtin.task_completion",
|
||||
"task_navigation_efficiency": "builtin.task_navigation_efficiency",
|
||||
# Tool usage
|
||||
"tool_call_accuracy": "builtin.tool_call_accuracy",
|
||||
"tool_selection": "builtin.tool_selection",
|
||||
"tool_input_accuracy": "builtin.tool_input_accuracy",
|
||||
"tool_output_utilization": "builtin.tool_output_utilization",
|
||||
"tool_call_success": "builtin.tool_call_success",
|
||||
# Quality
|
||||
"coherence": "builtin.coherence",
|
||||
"fluency": "builtin.fluency",
|
||||
"relevance": "builtin.relevance",
|
||||
"groundedness": "builtin.groundedness",
|
||||
"response_completeness": "builtin.response_completeness",
|
||||
"similarity": "builtin.similarity",
|
||||
# Safety
|
||||
"violence": "builtin.violence",
|
||||
"sexual": "builtin.sexual",
|
||||
"self_harm": "builtin.self_harm",
|
||||
"hate_unfairness": "builtin.hate_unfairness",
|
||||
}
|
||||
|
||||
# Default evaluator sets used when evaluators=None
|
||||
_DEFAULT_EVALUATORS: list[str] = [
|
||||
"relevance",
|
||||
"coherence",
|
||||
"task_adherence",
|
||||
]
|
||||
|
||||
_DEFAULT_TOOL_EVALUATORS: list[str] = [
|
||||
"tool_call_accuracy",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_evaluator(name: str) -> str:
|
||||
"""Resolve a short evaluator name to its fully-qualified ``builtin.*`` form.
|
||||
|
||||
Args:
|
||||
name: Short name (e.g. ``"relevance"``) or fully-qualified name
|
||||
(e.g. ``"builtin.relevance"``).
|
||||
|
||||
Returns:
|
||||
The fully-qualified evaluator name.
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is not recognized.
|
||||
"""
|
||||
if name.startswith("builtin."):
|
||||
return name
|
||||
resolved = _BUILTIN_EVALUATORS.get(name)
|
||||
if resolved is None:
|
||||
raise ValueError(f"Unknown evaluator '{name}'. Available: {sorted(_BUILTIN_EVALUATORS)}")
|
||||
return resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_testing_criteria(
|
||||
evaluators: Sequence[str],
|
||||
model_deployment: str,
|
||||
*,
|
||||
include_data_mapping: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build ``testing_criteria`` for ``evals.create()``.
|
||||
|
||||
Args:
|
||||
evaluators: Evaluator names.
|
||||
model_deployment: Model deployment for the LLM judge.
|
||||
include_data_mapping: Whether to include field-level data mapping
|
||||
(required for the JSONL data source, not needed for response-based).
|
||||
"""
|
||||
criteria: list[dict[str, Any]] = []
|
||||
for name in evaluators:
|
||||
qualified = _resolve_evaluator(name)
|
||||
short = name if not name.startswith("builtin.") else name.split(".")[-1]
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": "azure_ai_evaluator",
|
||||
"name": short,
|
||||
"evaluator_name": qualified,
|
||||
"initialization_parameters": {"deployment_name": model_deployment},
|
||||
}
|
||||
|
||||
if include_data_mapping:
|
||||
if qualified in _AGENT_EVALUATORS:
|
||||
# Agent evaluators: query/response as conversation arrays
|
||||
mapping: dict[str, str] = {
|
||||
"query": "{{item.query_messages}}",
|
||||
"response": "{{item.response_messages}}",
|
||||
}
|
||||
else:
|
||||
# Quality evaluators: query/response as strings
|
||||
mapping = {
|
||||
"query": "{{item.query}}",
|
||||
"response": "{{item.response}}",
|
||||
}
|
||||
if qualified == "builtin.groundedness":
|
||||
mapping["context"] = "{{item.context}}"
|
||||
if qualified in _TOOL_EVALUATORS:
|
||||
mapping["tool_definitions"] = "{{item.tool_definitions}}"
|
||||
entry["data_mapping"] = mapping
|
||||
|
||||
criteria.append(entry)
|
||||
return criteria
|
||||
|
||||
|
||||
def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) -> dict[str, Any]:
|
||||
"""Build the ``item_schema`` for custom JSONL eval definitions."""
|
||||
properties: dict[str, Any] = {
|
||||
"query": {"type": "string"},
|
||||
"response": {"type": "string"},
|
||||
"query_messages": {"type": "array"},
|
||||
"response_messages": {"type": "array"},
|
||||
}
|
||||
if has_context:
|
||||
properties["context"] = {"type": "string"}
|
||||
if has_tools:
|
||||
properties["tool_definitions"] = {"type": "array"}
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": ["query", "response"],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_default_evaluators(
|
||||
evaluators: Sequence[str] | None,
|
||||
items: Sequence[EvalItem | dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve evaluators, applying defaults when ``None``.
|
||||
|
||||
Defaults to relevance + coherence + task_adherence. Automatically adds
|
||||
tool_call_accuracy when items contain tools.
|
||||
"""
|
||||
if evaluators is not None:
|
||||
return list(evaluators)
|
||||
|
||||
result = list(_DEFAULT_EVALUATORS)
|
||||
if items is not None:
|
||||
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
|
||||
if has_tools:
|
||||
result.extend(_DEFAULT_TOOL_EVALUATORS)
|
||||
return result
|
||||
|
||||
|
||||
def _filter_tool_evaluators(
|
||||
evaluators: list[str],
|
||||
items: Sequence[EvalItem | dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""Remove tool evaluators if no items have tool definitions."""
|
||||
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
|
||||
if has_tools:
|
||||
return evaluators
|
||||
filtered = [e for e in evaluators if _resolve_evaluator(e) not in _TOOL_EVALUATORS]
|
||||
return filtered if filtered else list(_DEFAULT_EVALUATORS)
|
||||
|
||||
|
||||
async def _ensure_async_result(func: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Invoke a sync or async client method transparently.
|
||||
|
||||
If ``func`` returns a coroutine (async client), awaits it directly.
|
||||
Otherwise returns the already-resolved result.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
result = func(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
async def _poll_eval_run(
|
||||
client: AsyncOpenAI,
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
provider: str = "Microsoft Foundry",
|
||||
*,
|
||||
fetch_output_items: bool = True,
|
||||
) -> EvalResults:
|
||||
"""Poll an eval run until completion or timeout."""
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
run = await _ensure_async_result(client.evals.runs.retrieve, run_id=run_id, eval_id=eval_id)
|
||||
if run.status in ("completed", "failed", "canceled"):
|
||||
error_msg = None
|
||||
if run.status == "failed":
|
||||
error_msg = (
|
||||
getattr(run, "error", None)
|
||||
or getattr(run, "error_message", None)
|
||||
or getattr(run, "failure_reason", None)
|
||||
)
|
||||
if error_msg and not isinstance(error_msg, str):
|
||||
error_msg = str(error_msg)
|
||||
|
||||
items: list[EvalItemResult] = []
|
||||
if fetch_output_items and run.status == "completed":
|
||||
items = await _fetch_output_items(client, eval_id, run_id)
|
||||
|
||||
return EvalResults(
|
||||
provider=provider,
|
||||
eval_id=eval_id,
|
||||
run_id=run_id,
|
||||
status=run.status,
|
||||
result_counts=_extract_result_counts(run),
|
||||
report_url=getattr(run, "report_url", None),
|
||||
error=error_msg,
|
||||
per_evaluator=_extract_per_evaluator(run),
|
||||
items=items,
|
||||
)
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
return EvalResults(provider=provider, eval_id=eval_id, run_id=run_id, status="timeout")
|
||||
logger.debug("Eval run %s status: %s (%.0fs remaining)", run_id, run.status, remaining)
|
||||
await asyncio.sleep(min(poll_interval, remaining))
|
||||
|
||||
|
||||
def _extract_result_counts(run: Any) -> dict[str, int] | None:
|
||||
"""Safely extract result_counts from an eval run object."""
|
||||
counts = getattr(run, "result_counts", None)
|
||||
if counts is None:
|
||||
return None
|
||||
if isinstance(counts, dict):
|
||||
return cast(dict[str, int], counts)
|
||||
try:
|
||||
attrs = cast(dict[str, Any], vars(counts))
|
||||
return {str(k): v for k, v in attrs.items() if isinstance(v, int)}
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_per_evaluator(run: Any) -> dict[str, dict[str, int]]:
|
||||
"""Safely extract per-evaluator result breakdowns from an eval run."""
|
||||
per_eval: dict[str, dict[str, int]] = {}
|
||||
per_testing_criteria = getattr(run, "per_testing_criteria_results", None)
|
||||
if per_testing_criteria is None:
|
||||
return per_eval
|
||||
try:
|
||||
items = cast(list[Any], per_testing_criteria) if isinstance(per_testing_criteria, list) else [] # type: ignore[redundant-cast]
|
||||
for item in items:
|
||||
name: str = str(getattr(item, "name", None) or getattr(item, "testing_criteria", "unknown"))
|
||||
counts = _extract_result_counts(item)
|
||||
if name and counts:
|
||||
per_eval[name] = counts
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
return per_eval
|
||||
|
||||
|
||||
async def _fetch_output_items(
|
||||
client: AsyncOpenAI,
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
) -> list[EvalItemResult]:
|
||||
"""Fetch per-item results from the output_items API.
|
||||
|
||||
Converts the provider-specific ``OutputItemListResponse`` objects into
|
||||
provider-agnostic ``EvalItemResult`` instances with per-evaluator scores,
|
||||
error categorization, and token usage.
|
||||
"""
|
||||
items: list[EvalItemResult] = []
|
||||
try:
|
||||
output_items_page = await _ensure_async_result(
|
||||
client.evals.runs.output_items.list,
|
||||
run_id=run_id,
|
||||
eval_id=eval_id,
|
||||
)
|
||||
|
||||
for oi in output_items_page:
|
||||
item_id = getattr(oi, "id", "") or ""
|
||||
status = getattr(oi, "status", "unknown") or "unknown"
|
||||
|
||||
# Extract per-evaluator scores
|
||||
scores: list[EvalScoreResult] = []
|
||||
for r in getattr(oi, "results", []) or []:
|
||||
scores.append(
|
||||
EvalScoreResult(
|
||||
name=getattr(r, "name", "unknown"),
|
||||
score=getattr(r, "score", 0.0),
|
||||
passed=getattr(r, "passed", None),
|
||||
sample=getattr(r, "sample", None),
|
||||
)
|
||||
)
|
||||
|
||||
# Extract error info from sample
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
token_usage: dict[str, int] | None = None
|
||||
input_text: str | None = None
|
||||
output_text: str | None = None
|
||||
response_id: str | None = None
|
||||
|
||||
sample = getattr(oi, "sample", None)
|
||||
if sample is not None:
|
||||
error = getattr(sample, "error", None)
|
||||
if error is not None:
|
||||
code = getattr(error, "code", None)
|
||||
msg = getattr(error, "message", None)
|
||||
if code or msg:
|
||||
error_code = code or None
|
||||
error_message = msg or None
|
||||
|
||||
usage = getattr(sample, "usage", None)
|
||||
if usage is not None:
|
||||
total = getattr(usage, "total_tokens", 0)
|
||||
if total:
|
||||
token_usage = {
|
||||
"prompt_tokens": getattr(usage, "prompt_tokens", 0),
|
||||
"completion_tokens": getattr(usage, "completion_tokens", 0),
|
||||
"total_tokens": total,
|
||||
"cached_tokens": getattr(usage, "cached_tokens", 0),
|
||||
}
|
||||
|
||||
# Extract input/output text
|
||||
sample_input = getattr(sample, "input", None)
|
||||
if sample_input:
|
||||
parts = [getattr(si, "content", "") for si in sample_input if getattr(si, "role", "") == "user"]
|
||||
if parts:
|
||||
input_text = " ".join(parts)
|
||||
|
||||
sample_output = getattr(sample, "output", None)
|
||||
if sample_output:
|
||||
parts = [
|
||||
getattr(so, "content", "") or ""
|
||||
for so in sample_output
|
||||
if getattr(so, "role", "") == "assistant"
|
||||
]
|
||||
if parts:
|
||||
output_text = " ".join(parts)
|
||||
|
||||
# Extract response_id from datasource_item
|
||||
ds_item = getattr(oi, "datasource_item", None)
|
||||
if ds_item and isinstance(ds_item, dict):
|
||||
ds_dict = cast(dict[str, Any], ds_item)
|
||||
resp_id_val = ds_dict.get("resp_id") or ds_dict.get("response_id")
|
||||
response_id = str(resp_id_val) if resp_id_val else None
|
||||
|
||||
items.append(
|
||||
EvalItemResult(
|
||||
item_id=item_id,
|
||||
status=status,
|
||||
scores=scores,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
response_id=response_id,
|
||||
input_text=input_text,
|
||||
output_text=output_text,
|
||||
token_usage=token_usage,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not fetch output_items for run %s", run_id, exc_info=True)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _resolve_openai_client(
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Resolve an OpenAI client from explicit client or project_client."""
|
||||
if openai_client is not None:
|
||||
return openai_client
|
||||
if project_client is not None:
|
||||
return project_client.get_openai_client()
|
||||
raise ValueError("Provide either 'openai_client' or 'project_client'.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FoundryEvals — Evaluator implementation for Microsoft Foundry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FoundryEvals:
|
||||
"""Evaluation provider backed by Microsoft Foundry.
|
||||
|
||||
Implements the ``Evaluator`` protocol so it can be passed to the
|
||||
provider-agnostic ``evaluate_agent()`` and
|
||||
``evaluate_workflow()`` functions from ``agent_framework``.
|
||||
|
||||
Also provides constants for built-in evaluator names for IDE
|
||||
autocomplete and typo prevention::
|
||||
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evaluators = [FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY]
|
||||
|
||||
The simplest usage::
|
||||
|
||||
from agent_framework import evaluate_agent
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evals = FoundryEvals(project_client=client, model_deployment="gpt-4o")
|
||||
results = await evaluate_agent(agent=agent, queries=queries, evaluators=evals)
|
||||
|
||||
**Evaluator selection:**
|
||||
|
||||
By default, runs ``relevance``, ``coherence``, and ``task_adherence``.
|
||||
Automatically adds ``tool_call_accuracy`` when items contain tool
|
||||
definitions. Override with ``evaluators=``.
|
||||
|
||||
**Responses API optimization:**
|
||||
|
||||
When all items have a ``response_id`` and no tool evaluators are needed,
|
||||
uses Foundry's server-side response retrieval path (no data upload).
|
||||
|
||||
Args:
|
||||
project_client: An ``AIProjectClient`` instance (sync or async).
|
||||
Provide this or *openai_client*.
|
||||
openai_client: An ``AsyncOpenAI`` client with evals API.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
evaluators: Evaluator names (e.g. ``["relevance", "tool_call_accuracy"]``).
|
||||
When ``None`` (default), uses smart defaults based on item data.
|
||||
conversation_split: How to split multi-turn conversations into
|
||||
query/response halves. Defaults to ``LAST_TURN``. Pass a
|
||||
``ConversationSplit`` enum value or a custom callable — see
|
||||
``ConversationSplitter``.
|
||||
poll_interval: Seconds between status polls (default 5.0).
|
||||
timeout: Maximum seconds to wait for completion (default 600.0).
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in evaluator name constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Agent behavior
|
||||
INTENT_RESOLUTION: str = "intent_resolution"
|
||||
TASK_ADHERENCE: str = "task_adherence"
|
||||
TASK_COMPLETION: str = "task_completion"
|
||||
TASK_NAVIGATION_EFFICIENCY: str = "task_navigation_efficiency"
|
||||
|
||||
# Tool usage
|
||||
TOOL_CALL_ACCURACY: str = "tool_call_accuracy"
|
||||
TOOL_SELECTION: str = "tool_selection"
|
||||
TOOL_INPUT_ACCURACY: str = "tool_input_accuracy"
|
||||
TOOL_OUTPUT_UTILIZATION: str = "tool_output_utilization"
|
||||
TOOL_CALL_SUCCESS: str = "tool_call_success"
|
||||
|
||||
# Quality
|
||||
COHERENCE: str = "coherence"
|
||||
FLUENCY: str = "fluency"
|
||||
RELEVANCE: str = "relevance"
|
||||
GROUNDEDNESS: str = "groundedness"
|
||||
RESPONSE_COMPLETENESS: str = "response_completeness"
|
||||
SIMILARITY: str = "similarity"
|
||||
|
||||
# Safety
|
||||
VIOLENCE: str = "violence"
|
||||
SEXUAL: str = "sexual"
|
||||
SELF_HARM: str = "self_harm"
|
||||
HATE_UNFAIRNESS: str = "hate_unfairness"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
project_client: AIProjectClient | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
model_deployment: str,
|
||||
evaluators: Sequence[str] | None = None,
|
||||
conversation_split: ConversationSplitter = ConversationSplit.LAST_TURN,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
):
|
||||
self.name = "Microsoft Foundry"
|
||||
self._client = _resolve_openai_client(openai_client, project_client)
|
||||
self._model_deployment = model_deployment
|
||||
self._evaluators = list(evaluators) if evaluators is not None else None
|
||||
self._conversation_split = conversation_split
|
||||
self._poll_interval = poll_interval
|
||||
self._timeout = timeout
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
items: Sequence[EvalItem],
|
||||
*,
|
||||
eval_name: str = "Agent Framework Eval",
|
||||
) -> EvalResults:
|
||||
"""Evaluate items using Foundry evaluators.
|
||||
|
||||
Implements the ``Evaluator`` protocol. Automatically selects the
|
||||
optimal data path (Responses API vs JSONL dataset) and filters
|
||||
tool evaluators for items without tool definitions.
|
||||
|
||||
Args:
|
||||
items: Eval data items from ``AgentEvalConverter.to_eval_item()``.
|
||||
eval_name: Display name for the evaluation run.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, counts, and portal link.
|
||||
"""
|
||||
# Resolve evaluators with auto-detection
|
||||
resolved = _resolve_default_evaluators(self._evaluators, items=items)
|
||||
# Filter tool evaluators if items don't have tools
|
||||
resolved = _filter_tool_evaluators(resolved, items)
|
||||
|
||||
# Standard JSONL dataset path
|
||||
return await self._evaluate_via_dataset(items, resolved, eval_name)
|
||||
|
||||
# -- Internal evaluation paths --
|
||||
|
||||
async def _evaluate_via_responses(
|
||||
self,
|
||||
response_ids: Sequence[str],
|
||||
evaluators: list[str],
|
||||
eval_name: str,
|
||||
) -> EvalResults:
|
||||
"""Evaluate using Foundry's Responses API retrieval path."""
|
||||
eval_obj = await _ensure_async_result(
|
||||
self._client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={"type": "azure_ai_source", "scenario": "responses"},
|
||||
testing_criteria=_build_testing_criteria(evaluators, self._model_deployment),
|
||||
)
|
||||
|
||||
data_source = {
|
||||
"type": "azure_ai_responses",
|
||||
"item_generation_params": {
|
||||
"type": "response_retrieval",
|
||||
"data_mapping": {"response_id": "{{item.resp_id}}"},
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": {"resp_id": rid}} for rid in response_ids],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
self._client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(
|
||||
self._client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
self._poll_interval,
|
||||
self._timeout,
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
async def _evaluate_via_dataset(
|
||||
self,
|
||||
items: Sequence[EvalItem],
|
||||
evaluators: list[str],
|
||||
eval_name: str,
|
||||
) -> EvalResults:
|
||||
"""Evaluate using JSONL dataset upload path."""
|
||||
dicts = [item.to_eval_data(split=item.split_strategy or self._conversation_split) for item in items]
|
||||
has_context = any("context" in d for d in dicts)
|
||||
has_tools = any("tool_definitions" in d for d in dicts)
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
self._client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={
|
||||
"type": "custom",
|
||||
"item_schema": _build_item_schema(has_context=has_context, has_tools=has_tools),
|
||||
"include_sample_schema": True,
|
||||
},
|
||||
testing_criteria=_build_testing_criteria(
|
||||
evaluators,
|
||||
self._model_deployment,
|
||||
include_data_mapping=True,
|
||||
),
|
||||
)
|
||||
|
||||
data_source = {
|
||||
"type": "jsonl",
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": d} for d in dicts],
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
self._client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(
|
||||
self._client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
self._poll_interval,
|
||||
self._timeout,
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific functions (not part of the Evaluator protocol)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def evaluate_traces(
|
||||
*,
|
||||
evaluators: Sequence[str] | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
model_deployment: str,
|
||||
response_ids: Sequence[str] | None = None,
|
||||
trace_ids: Sequence[str] | None = None,
|
||||
agent_id: str | None = None,
|
||||
lookback_hours: int = 24,
|
||||
eval_name: str = "Agent Framework Trace Eval",
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
) -> EvalResults:
|
||||
"""Evaluate agent behavior from OTel traces or response IDs.
|
||||
|
||||
Foundry-specific function — works with any agent that emits OTel traces
|
||||
to App Insights. Provide *response_ids* for specific responses,
|
||||
*trace_ids* for specific traces, or *agent_id* with *lookback_hours*
|
||||
to evaluate recent activity.
|
||||
|
||||
Args:
|
||||
evaluators: Evaluator names (e.g. ``[FoundryEvals.RELEVANCE]``).
|
||||
Defaults to relevance, coherence, and task_adherence.
|
||||
openai_client: ``AsyncOpenAI`` client. Provide this or *project_client*.
|
||||
project_client: An ``AIProjectClient`` instance.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
response_ids: Evaluate specific Responses API responses.
|
||||
trace_ids: Evaluate specific OTel trace IDs from App Insights.
|
||||
agent_id: Filter traces by agent ID (used with *lookback_hours*).
|
||||
lookback_hours: Hours of trace history to evaluate (default 24).
|
||||
eval_name: Display name for the evaluation.
|
||||
poll_interval: Seconds between status polls.
|
||||
timeout: Maximum seconds to wait for completion.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, result counts, and portal link.
|
||||
|
||||
Example::
|
||||
|
||||
results = await evaluate_traces(
|
||||
response_ids=[response.response_id],
|
||||
evaluators=[FoundryEvals.RELEVANCE],
|
||||
project_client=project_client,
|
||||
model_deployment="gpt-4o",
|
||||
)
|
||||
"""
|
||||
client = _resolve_openai_client(openai_client, project_client)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
if response_ids:
|
||||
foundry = FoundryEvals(
|
||||
openai_client=client,
|
||||
model_deployment=model_deployment,
|
||||
evaluators=resolved_evaluators,
|
||||
poll_interval=poll_interval,
|
||||
timeout=timeout,
|
||||
)
|
||||
return await foundry._evaluate_via_responses( # pyright: ignore[reportPrivateUsage]
|
||||
response_ids,
|
||||
resolved_evaluators,
|
||||
eval_name,
|
||||
)
|
||||
|
||||
if not trace_ids and not agent_id:
|
||||
raise ValueError("Provide at least one of: response_ids, trace_ids, or agent_id")
|
||||
|
||||
trace_source: dict[str, Any] = {
|
||||
"type": "azure_ai_traces",
|
||||
"lookback_hours": lookback_hours,
|
||||
}
|
||||
if trace_ids:
|
||||
trace_source["trace_ids"] = list(trace_ids)
|
||||
if agent_id:
|
||||
trace_source["agent_id"] = agent_id
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={"type": "azure_ai_source", "scenario": "traces"},
|
||||
testing_criteria=_build_testing_criteria(resolved_evaluators, model_deployment),
|
||||
)
|
||||
|
||||
run = await _ensure_async_result(
|
||||
client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=trace_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
|
||||
|
||||
async def evaluate_foundry_target(
|
||||
*,
|
||||
target: dict[str, Any],
|
||||
test_queries: Sequence[str],
|
||||
evaluators: Sequence[str] | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
model_deployment: str,
|
||||
eval_name: str = "Agent Framework Target Eval",
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
) -> EvalResults:
|
||||
"""Evaluate a Foundry-registered agent or model deployment.
|
||||
|
||||
Foundry invokes the target, captures the output, and evaluates it. Use
|
||||
this for scheduled evals, red teaming, and CI/CD quality gates.
|
||||
|
||||
Args:
|
||||
target: Target configuration dict.
|
||||
test_queries: Queries for Foundry to send to the target.
|
||||
evaluators: Evaluator names.
|
||||
openai_client: ``AsyncOpenAI`` client. Provide this or *project_client*.
|
||||
project_client: An ``AIProjectClient`` instance.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
eval_name: Display name for the evaluation.
|
||||
poll_interval: Seconds between status polls.
|
||||
timeout: Maximum seconds to wait for completion.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, result counts, and portal link.
|
||||
|
||||
Example::
|
||||
|
||||
results = await evaluate_foundry_target(
|
||||
target={"type": "azure_ai_agent", "name": "my-agent"},
|
||||
test_queries=["Book a flight to Paris"],
|
||||
project_client=project_client,
|
||||
model_deployment="gpt-4o",
|
||||
)
|
||||
"""
|
||||
client = _resolve_openai_client(openai_client, project_client)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={
|
||||
"type": "azure_ai_source",
|
||||
"scenario": "target_completions",
|
||||
},
|
||||
testing_criteria=_build_testing_criteria(resolved_evaluators, model_deployment),
|
||||
)
|
||||
|
||||
data_source: dict[str, Any] = {
|
||||
"type": "azure_ai_target_completions",
|
||||
"target": target,
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": {"query": q}} for q in test_queries],
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
@@ -24,9 +24,9 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-ai-agents == 1.2.0b5",
|
||||
"azure-ai-inference>=1.0.0b9",
|
||||
"aiohttp",
|
||||
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"aiohttp>=3.7.0,<4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -85,11 +85,16 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
help = "Run the package integration test suite."
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-n logical --dist worksteal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -124,7 +124,13 @@ class CosmosHistoryProvider(BaseHistoryProvider):
|
||||
|
||||
self._database_client = self._cosmos_client.get_database_client(self.database_name)
|
||||
|
||||
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]:
|
||||
async def get_messages(
|
||||
self,
|
||||
session_id: str | None,
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[Message]:
|
||||
"""Retrieve stored messages for this session from Azure Cosmos DB."""
|
||||
await self._ensure_container_proxy()
|
||||
session_key = self._session_partition_key(session_id)
|
||||
@@ -157,7 +163,14 @@ class CosmosHistoryProvider(BaseHistoryProvider):
|
||||
|
||||
return messages
|
||||
|
||||
async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None:
|
||||
async def save_messages(
|
||||
self,
|
||||
session_id: str | None,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
state: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Persist messages for this session to Azure Cosmos DB."""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-cosmos>=4.9.0",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -84,10 +84,17 @@ exclude_dirs = ["tests"]
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
|
||||
integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
help = "Run the package integration test suite."
|
||||
cmd = "pytest tests/test_cosmos_history_provider.py -m integration"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -24,8 +24,8 @@ classifiers = [
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions",
|
||||
"azure-functions-durable",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -91,9 +91,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -236,11 +236,11 @@ class BedrockChatClient(
|
||||
session_token: str | None = None,
|
||||
client: BaseClient | None = None,
|
||||
boto3_session: Boto3Session | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a Bedrock chat client and load AWS credentials.
|
||||
|
||||
@@ -252,11 +252,11 @@ class BedrockChatClient(
|
||||
session_token: Optional AWS session token for temporary credentials.
|
||||
client: Preconfigured Bedrock runtime client; when omitted a boto3 session is created.
|
||||
boto3_session: Custom boto3 session used to build the runtime client if provided.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional sequence of middlewares to include.
|
||||
function_invocation_configuration: Optional function invocation configuration
|
||||
env_file_path: Optional .env file path used by ``BedrockSettings`` to load defaults.
|
||||
env_file_encoding: Encoding for the optional .env file.
|
||||
kwargs: Additional arguments forwarded to ``BaseChatClient``.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -303,9 +303,9 @@ class BedrockChatClient(
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
**kwargs,
|
||||
)
|
||||
self.model_id = chat_model_id
|
||||
self.region = region
|
||||
|
||||
@@ -104,9 +104,9 @@ class RawBedrockEmbeddingClient(
|
||||
session_token: str | None = None,
|
||||
client: BaseClient | None = None,
|
||||
boto3_session: Boto3Session | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a raw Bedrock embedding client."""
|
||||
settings = load_settings(
|
||||
@@ -145,7 +145,7 @@ class RawBedrockEmbeddingClient(
|
||||
|
||||
self.model_id: str = settings["embedding_model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
|
||||
self.region = resolved_region
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(additional_properties=additional_properties)
|
||||
|
||||
def service_url(self) -> str:
|
||||
"""Get the URL of the service."""
|
||||
@@ -274,9 +274,9 @@ class BedrockEmbeddingClient(
|
||||
client: BaseClient | None = None,
|
||||
boto3_session: Boto3Session | None = None,
|
||||
otel_provider_name: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize a Bedrock embedding client."""
|
||||
super().__init__(
|
||||
@@ -287,8 +287,8 @@ class BedrockEmbeddingClient(
|
||||
session_token=session_token,
|
||||
client=client,
|
||||
boto3_session=boto3_session,
|
||||
additional_properties=additional_properties,
|
||||
otel_provider_name=otel_provider_name,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -84,9 +84,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"openai-chatkit>=1.4.0,<2.0.0",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -86,9 +86,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -590,6 +590,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = None,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@@ -600,6 +601,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
@@ -609,7 +611,8 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
options: OptionsT | None = None,
|
||||
**kwargs: Any, # type: ignore
|
||||
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
|
||||
"""Run the agent with the given messages.
|
||||
|
||||
@@ -621,16 +624,16 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
returns an awaitable AgentResponse.
|
||||
session: The conversation session. If session has service_session_id set,
|
||||
the agent will resume that session.
|
||||
kwargs: Additional keyword arguments including 'options' for runtime options
|
||||
(model, permission_mode can be changed per-request).
|
||||
options: Runtime options. Model and permission_mode can be changed per request.
|
||||
kwargs: Additional keyword arguments for compatibility with the shared agent
|
||||
interface (e.g. compaction_strategy, tokenizer). Not used by ClaudeAgent.
|
||||
|
||||
Returns:
|
||||
When stream=True: An ResponseStream for streaming updates.
|
||||
When stream=False: An Awaitable[AgentResponse] with the complete response.
|
||||
"""
|
||||
options = kwargs.pop("options", None)
|
||||
response = ResponseStream(
|
||||
self._get_stream(messages, session=session, options=options, **kwargs),
|
||||
self._get_stream(messages, session=session, options=options),
|
||||
finalizer=self._finalize_response,
|
||||
)
|
||||
|
||||
@@ -643,8 +646,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
options: OptionsT | MutableMapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
options: OptionsT | None = None,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
"""Internal streaming implementation."""
|
||||
session = session or self.create_session()
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"claude-agent-sdk>=0.1.25",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -86,9 +86,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -196,7 +196,6 @@ class CopilotStudioAgent(BaseAgent):
|
||||
*,
|
||||
stream: Literal[False] = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse]: ...
|
||||
|
||||
@overload
|
||||
@@ -206,7 +205,6 @@ class CopilotStudioAgent(BaseAgent):
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
|
||||
|
||||
def run(
|
||||
@@ -215,7 +213,6 @@ class CopilotStudioAgent(BaseAgent):
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
"""Get a response from the agent.
|
||||
|
||||
@@ -229,22 +226,20 @@ class CopilotStudioAgent(BaseAgent):
|
||||
Keyword Args:
|
||||
stream: Whether to stream the response. Defaults to False.
|
||||
session: The conversation session associated with the message(s).
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
When stream=False: An Awaitable[AgentResponse].
|
||||
When stream=True: A ResponseStream of AgentResponseUpdate items.
|
||||
"""
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, session=session, **kwargs)
|
||||
return self._run_impl(messages=messages, session=session, **kwargs)
|
||||
return self._run_stream_impl(messages=messages, session=session)
|
||||
return self._run_impl(messages=messages, session=session)
|
||||
|
||||
async def _run_impl(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
"""Non-streaming implementation of run."""
|
||||
if not session:
|
||||
@@ -269,7 +264,6 @@ class CopilotStudioAgent(BaseAgent):
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
"""Streaming implementation of run."""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user