chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
{
"version": "0.2",
"languageSettings": [
{
"languageId": "py",
"allowCompoundWords": true,
"locale": "en-US"
}
],
"language": "en-US",
"patterns": [
{
"name": "import",
"pattern": "import [a-zA-Z0-9_]+"
},
{
"name": "from import",
"pattern": "from [a-zA-Z0-9_]+ import [a-zA-Z0-9_]+"
}
],
"ignorePaths": [
"samples/**",
"notebooks/**"
],
"words": [
"aeiou",
"agentserver",
"agui",
"aiplatform",
"azuredocindex",
"azuredocs",
"azurefunctions",
"boto",
"codeact",
"contentvector",
"contoso",
"datamodel",
"desync",
"dotenv",
"endregion",
"entra",
"faiss",
"finalizer",
"finalizers",
"genai",
"generativeai",
"hnsw",
"httpx",
"huggingface",
"hyperlight",
"Instrumentor",
"logit",
"logprobs",
"lowlevel",
"Magentic",
"mistralai",
"mongocluster",
"nd",
"ndarray",
"nopep",
"NOSQL",
"ollama",
"Onnx",
"onyourdatatest",
"OPENAI",
"opentelemetry",
"OTEL",
"otlp",
"powerfx",
"protos",
"pydantic",
"pytestmark",
"qdrant",
"retrywrites",
"serde",
"streamable",
"superstep",
"supersteps",
"templating",
"uninstrument",
"vectordb",
"vectorizable",
"vectorizer",
"vectorstoremodel",
"vertexai",
"Weaviate"
]
}
+52
View File
@@ -0,0 +1,52 @@
# Microsoft Foundry
FOUNDRY_PROJECT_ENDPOINT=""
# Model used for FoundryChatClient
FOUNDRY_MODEL=""
# Foundry Agents (prompt or hosted agents)
FOUNDRY_AGENT_NAME=""
FOUNDRY_AGENT_VERSION=""
# Microsoft Foundry Models endpoint, used by embeddings
FOUNDRY_MODELS_ENDPOINT=""
FOUNDRY_MODELS_API_KEY=""
FOUNDRY_EMBEDDING_MODEL=""
FOUNDRY_IMAGE_EMBEDDING_MODEL=""
# Bing connection for web search (optional, used by samples with web search)
BING_CONNECTION_ID=""
# Azure AI Search (optional, used by AzureAISearchContextProvider samples)
AZURE_SEARCH_ENDPOINT=""
AZURE_SEARCH_API_KEY=""
AZURE_SEARCH_INDEX_NAME=""
AZURE_SEARCH_SEMANTIC_CONFIG=""
AZURE_SEARCH_KNOWLEDGE_BASE_NAME=""
# Note: For agentic mode Knowledge Bases, also set AZURE_OPENAI_ENDPOINT below
# (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls)
# OpenAI
OPENAI_API_KEY=""
OPENAI_CHAT_COMPLETION_MODEL=""
OPENAI_CHAT_MODEL=""
# Azure OpenAI
AZURE_OPENAI_ENDPOINT=""
AZURE_OPENAI_CHAT_COMPLETION_MODEL=""
AZURE_OPENAI_CHAT_MODEL=""
# Mem0
MEM0_API_KEY=""
# Copilot Studio
COPILOTSTUDIOAGENT__ENVIRONMENTID=""
COPILOTSTUDIOAGENT__SCHEMANAME=""
COPILOTSTUDIOAGENT__TENANTID=""
COPILOTSTUDIOAGENT__AGENTAPPID=""
# Anthropic
ANTHROPIC_API_KEY=""
ANTHROPIC_MODEL=""
# Google Gemini
GEMINI_API_KEY=""
GEMINI_MODEL=""
# Ollama
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
# Mistral AI
MISTRAL_API_KEY=""
MISTRAL_EMBEDDING_MODEL=""
# Observability (instrumentation is enabled by default; set "ENABLE_INSTRUMENTATION" to "false" to opt out)
ENABLE_SENSITIVE_DATA=true
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
+10
View File
@@ -0,0 +1,10 @@
---
applyTo: 'python/**'
---
See [AGENTS.md](../../AGENTS.md) for project structure and package documentation.
Detailed conventions are in the agent skills under `.github/skills/`.
When changing the public root API surface (`agent_framework/__init__.py`), keep the lazy runtime export
registry, explicit runtime `__all__`, and `agent_framework/__init__.pyi` synchronized. Runtime deprecation
behavior for a public alias should live in the owning module, not as a special case in the root package.
@@ -0,0 +1,361 @@
---
name: agent-framework-py-release
description: Use when cutting a Python release for the microsoft/agent-framework monorepo. Triggers on "bump py versions", "cut a python release", "prepare release PR for python", "release py packages", "bump python to X.Y.Z", or similar requests to bump Python package versions and prepare a release PR. Handles all four lifecycle tiers (alpha, beta, rc, released) with CHANGELOG-driven selective bumps, floor bound checks, and post-bump validation.
---
# Agent Framework Python Release
Cuts a Python release PR for the `microsoft/agent-framework` monorepo.
## Core principle: CHANGELOG drives bumps
**No more universal lockstep.** A package only bumps if it has a CHANGELOG entry this cycle. Draft the CHANGELOG first, then derive the bump list from it.
Exceptions to selective bumping (these stay coupled regardless of per-package changes):
- **Root `agent-framework` is coupled to `agent-framework-core` via `agent-framework-core[all]==X.Y.Z`** (exact pin in `python/pyproject.toml`). If `core` bumps, root bumps. Root may also bump independently for its own changes (docs/metadata/extras).
- **Beta-tier cohort bumps are still allowed** when the user explicitly wants a fresh date stamp across all betas for cohort signaling — but this is a deliberate user decision per release, not a default.
If the user explicitly states which packages to bump (or hands over a PR list per package), treat that as authoritative.
## Lifecycle tiers and version rules
Use `python/.github/skills/python-package-management/SKILL.md` as the source of truth for lifecycle
version patterns, date-stamp cutoffs, classifier alignment, and internal dependency updates.
For release work, derive the live tier map at release time from `python/PACKAGE_STATUS.md` and the actual
`version =` lines in each `pyproject.toml`. Do not hardcode counts — packages move between tiers.
## Inputs to confirm before bumping
1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..origin/main -- python/`.
2. **Per-package CHANGELOG entries**: which packages will get a line in the new release section. This list IS the bump list.
3. **Per-released-package semver bump**: for each released-tier package that has a CHANGELOG entry, decide PATCH / MINOR / MAJOR.
4. **Date stamp** (only if any alpha/beta is being bumped): default from the `python-package-management`
date-stamp rule.
5. **Optional cohort bump?**: ask if betas should all get a new date stamp regardless of per-package changes. Default: no.
If the user states target versions or a date explicitly, use exactly what they said — do not "correct" based on historical timezones.
## Non-negotiable rules
- **CHANGELOG-driven bumps**: only packages mentioned in the new CHANGELOG section get version bumps. Exceptions: root follows core (==pin); user-opted cohort bump on betas.
- **Follow `python-package-management` for package lifecycle and versioning rules** — do not duplicate those
rules in this release workflow.
- **No `Co-Authored-By` trailer** on any commit.
- **Use `uv run`** for all Python/poe commands (`uv run poe ...`, `uv run pytest ...`).
- **Never rename an existing CHANGELOG section header** during a new release cut. Only INSERT a new section above existing ones.
- **Footer reference links are part of the CHANGELOG edit**, not optional.
## Workflow
### 1. Orient and create branch
```bash
git fetch origin main --tags --quiet
git fetch upstream main --tags --quiet 2>/dev/null || true
git status
```
If the user already has a `bump-py-ver-release-*` branch checked out, use it. Otherwise:
```bash
git checkout -b bump-py-ver-release-YYMMDD origin/main
```
### 2. Build the live tier map
Read `python/PACKAGE_STATUS.md` to enumerate current packages and lifecycle stages, and `grep '^version' python/pyproject.toml python/packages/*/pyproject.toml` to confirm actual versions. Cross-reference — if `PACKAGE_STATUS.md` shows a package as `beta` but its version looks like `1.0.0aYYMMDD`, surface the inconsistency before proceeding.
Watch for: new packages added since the last release, lifecycle transitions (alpha→beta, beta→rc, rc→released), and stale packages that haven't been touched in many cycles.
### 3. Identify changeset
Use the LATEST released tag as the compare base:
```bash
LAST_RELEASED_TAG=$(
git tag -l 'python-[0-9]*.[0-9]*.[0-9]*' \
| uv run --directory python python -c 'import sys; tags=[t.strip() for t in sys.stdin if t.strip()]; print(max(tags, key=lambda t: tuple(map(int, t[7:].split(".")))))'
)
echo "Compare base: $LAST_RELEASED_TAG"
```
List commits and packages touched:
```bash
git log --oneline ${LAST_RELEASED_TAG}..origin/main -- python/ ':!python/CHANGELOG.md'
# Per-commit package footprint
for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..origin/main -- python/); do
echo "--- $(git show -s --format='%h %s' $sha) ---"
git show --name-only --format='' $sha | grep '^python/packages/' | \
sed 's|^python/packages/||' | awk -F/ '{print $1}' | sort -u
done
```
If the release ultimately tags from `upstream/main` but `origin/main` is behind, also run:
```bash
git log --oneline ${LAST_RELEASED_TAG}..upstream/main -- python/ ':!python/CHANGELOG.md'
```
If user provides an explicit commit/PR list, treat THAT as authoritative.
#### 3a. Build the authoritative touched-package set
Aggregate the per-commit footprint into a single union across the whole range. This is the **source of truth** for what must appear in the CHANGELOG. Save it before drafting entries.
```bash
# Union of all touched package directories across the range
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
| grep '^python/packages/' \
| sed 's|^python/packages/||' \
| awk -F/ '{print $1}' \
| sort -u
# Root-level files (drive a root agent-framework entry if substantive)
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
2>/dev/null | grep -v '^$' | sort -u
```
**What "touched" means for bump purposes** (apply judgment, document in commit message):
| Change scope under `python/packages/<pkg>/` | Drives a bump? |
| --- | --- |
| Source code under `<pkg>/agent_framework_*` or the package's library tree | **Yes** — ship-affecting |
| `pyproject.toml` (deps, classifiers, extras, version metadata) | **Yes** — publishes new metadata |
| `README.md` substantive changes (env vars, install instructions, usage) | **Yes** — user-facing |
| `README.md` typo/wording-only | Judgment — usually no, but record under `### Changed` if you do bump |
| Tests only (`tests/`) | Usually no — test changes don't ship to PyPI consumers. Bump only if the test change reflects a real behavior change in the package |
| Package-local samples (alpha packages only, before promotion) | **Yes** for alpha; samples ship with the alpha package |
| Repo infra touching the package dir (CI config, lint config) | No — record under `- **tests**:`, `- **samples**:`, or `- **docs**:` instead |
Root `agent-framework` is touched when `python/pyproject.toml`, `python/agent_framework_meta/`, or root `README.md` substantive content changed.
### 4. Draft CHANGELOG entries (THIS DRIVES THE BUMP LIST)
Locate `## [Unreleased]` and the top existing release header. INSERT a new section between them.
**New section structure:**
```markdown
## [<release-label>] - YYYY-MM-DD
### Added
- **agent-framework-<pkg>**: <subject> ([#NNNN](https://github.com/microsoft/agent-framework/pull/NNNN))
### Changed
- **agent-framework-<pkg>**: ...
### Fixed
- **agent-framework-<pkg>**: ...
```
`<release-label>` is the released-tier target if any released package is bumping (e.g. `1.7.0`), or the date stamp (e.g. `1.0.0b260528`) if the release is prerelease-only. Reflect the user's framing.
**Categorization heuristics (read commit subject + PR title/body):**
- **Added**: new public APIs, new packages, new samples, `feat(...)`, new re-exports from `agent_framework`, new capabilities surfaced from underlying SDKs
- **Changed**: metadata/classifier corrections, dependency upgrades, test infra changes, signature adjustments on existing APIs, renames, doc updates, lifecycle transitions
- **Fixed**: `fix(...)`, bug/crash/regression repairs, noise reduction (e.g. stop emitting spurious warnings), protocol-compliance fixes
- **Removed**: deprecations/deletions (use `[BREAKING]` prefix if breaking)
**Entry format:**
- `- **agent-framework-<pkg>**: <subject, grammar-normalized> ([#NNNN](https://github.com/microsoft/agent-framework/pull/NNNN))`
- Multiple packages touched by one PR: comma-separated bold names at the head: `- **agent-framework-core**, **agent-framework-foundry**: ...`
- Root package changes: use `- **agent-framework**: ...`
- Test/sample/repo infra: `- **tests**: ...`, `- **samples**: ...`, `- **docs**: ...` (these do not drive package bumps)
**Once entries are drafted, the set of `**agent-framework-<pkg>**` and root `**agent-framework**` mentions IS the bump list.** Anything not mentioned does not bump.
#### 4a. Reconcile mentions against the touched-package set (DO NOT SKIP)
Before moving on, prove that every ship-affecting touched package has at least one CHANGELOG entry. A package whose code changed but is missing from CHANGELOG will NOT bump — and the fix will not ship to PyPI consumers.
```bash
# 1. Touched ship-affecting packages and root package files (from step 3a)
TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
| grep '^python/packages/' \
| sed 's|^python/packages/||' \
| awk -F/ '{print $1}' \
| sort -u)
ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
2>/dev/null | grep -v '^$' | sort -u)
TOUCHED=$(
{
if [ -n "$TOUCHED_PACKAGES" ]; then echo "$TOUCHED_PACKAGES"; fi
if [ -n "$ROOT_TOUCHED" ]; then echo "agent-framework"; fi
} | sort -u
)
# 2. Packages mentioned in the new CHANGELOG section
# (Extract from the section you just drafted — adjust the awk range to the new section's bounds)
MENTIONED=$(awk '
/^## \[<release-label>\]/ { in_section=1; next }
in_section && /^## \[/ { exit }
in_section { print }
' python/CHANGELOG.md \
| grep -oE '\*\*agent-framework(-[a-z0-9_-]+)?\*\*' \
| sed 's/\*\*//g;s/^agent-framework-//' \
| sort -u)
# 3. Diff — anything in TOUCHED but missing from MENTIONED is a gap
comm -23 <(echo "$TOUCHED") <(echo "$MENTIONED")
```
For every gap, decide explicitly with the user:
- **Add a CHANGELOG entry and bump** — the default. Even small package changes (a dep upgrade, a metadata fix) deserve an entry under `### Changed`.
- **Intentionally skip** — only when the touched files are demonstrably non-shipping (e.g. comments-only, test infra). Note the skip in the commit message body so reviewers see the reasoning.
Package-name aliasing to watch for: directory `foundry_local` → package `agent-framework-foundry-local`; `github_copilot` → `agent-framework-github-copilot`; `azure-ai-search` → `agent-framework-azure-ai-search`. The directory name and the published PyPI name are not always identical — confirm both when reconciling.
**Footer reference links** (REQUIRED every release):
```bash
grep -n "^\[.*\]:" python/CHANGELOG.md | head -5
```
Two edits when a released-tier bump is in play:
1. Advance `[Unreleased]` compare base from `python-${OLD_RELEASED}...HEAD` to `python-${NEW_RELEASED}...HEAD`.
2. INSERT a new `[${NEW_RELEASED}]` line ABOVE the previous version's link: `[${NEW_RELEASED}]: https://github.com/microsoft/agent-framework/compare/python-${OLD_RELEASED}...python-${NEW_RELEASED}`
For prerelease-only releases (no released-tier bump this cycle), footer links don't change.
### 5. Apply bumps per tier
For each package in the bump list, choose the rule for its current tier. Use anchored `sed` (`^version = "..."$`) so only the project's own version matches. Use `sed -i.bak` plus backup cleanup for portable in-place edits across macOS/BSD sed and GNU sed.
**Released tier (per-package semver):**
```bash
# Example: openai goes 1.6.0 -> 1.6.1 (PATCH); core stays
sed -i.bak 's/^version = "1.6.0"$/version = "1.6.1"/' python/packages/openai/pyproject.toml
rm python/packages/openai/pyproject.toml.bak
```
**Root `agent-framework` (only when `core` bumps, OR for its own changes):**
```bash
sed -i.bak "s/^version = \"${OLD_ROOT}\"$/version = \"${NEW_ROOT}\"/" python/pyproject.toml
# AND keep the exact pin in sync with core
sed -i.bak "s/agent-framework-core\\[all\\]==${OLD_CORE}/agent-framework-core[all]==${NEW_CORE}/" python/pyproject.toml
rm python/pyproject.toml.bak
```
**RC tier (counter increment):**
```bash
# ag-ui goes 1.0.0rc3 -> 1.0.0rc4 only because it has a CHANGELOG entry
sed -i.bak 's/^version = "1.0.0rc3"$/version = "1.0.0rc4"/' python/packages/ag-ui/pyproject.toml
rm python/packages/ag-ui/pyproject.toml.bak
```
**Alpha/Beta tier (new date stamp):**
```bash
OLD_DATE=260521
NEW_DATE=260528
# Only the packages with CHANGELOG entries get the new stamp.
# Example: anthropic and bedrock had entries, others did not.
for pkg in anthropic bedrock; do
file="python/packages/$pkg/pyproject.toml"
sed -i.bak "s/1\\.0\\.0b${OLD_DATE}/1.0.0b${NEW_DATE}/g" "$file"
rm "${file}.bak"
done
```
If the user opts into a cohort-wide beta bump regardless of per-package changes, enumerate every beta package from `PACKAGE_STATUS.md` and stamp all of them. State that this is a cohort bump in the commit message.
Spot-check with `grep '^version' python/pyproject.toml python/packages/*/pyproject.toml | sort` before moving on.
### 6. Floor bound updates on `agent-framework-core`
Only relevant when `core` itself bumped this cycle. Two policies, pick one explicitly with the user:
- **Conservative (default)**: raise `agent-framework-core>=X.Y.Z` to the new core version on every non-core package that is ALSO bumping this cycle. Leaves packages-not-bumped at their existing floor.
- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection. Use only when the user is comfortable letting `validate-dependency-bounds-test` (lower-resolution pass) catch any mistakes.
When raising a core floor, replace only the `>=OLD` half of the bound you intend to change:
```bash
file="python/packages/<pkg>/pyproject.toml"
sed -i.bak "s/agent-framework-core>=${OLD_CORE}/agent-framework-core>=${NEW_CORE}/" "$file"
rm "${file}.bak"
```
If `core` did not bump this cycle, do not touch floors.
### 7. Validate
```bash
cd python && uv run poe validate-dependency-bounds-test
```
Must exit 0. This is the safety net for selective bumping: the lower-resolution pass catches floors set too low for code that depends on new APIs, and the upper pass catches caps that exclude installable versions. If it fails, the output names the offending bound — fix and re-run before committing. This step also regenerates `uv.lock` to match new bounds.
If only prereleases changed (no `core` bump, no floor changes), this validation is still required — `uv.lock` regeneration alone justifies the run.
### 8. Commit (expect hook retry)
The project's pre-commit hook runs `poe check` / `poe typing` / `uv lock` and may re-modify `uv.lock` on first commit attempt. Expected pattern — run twice:
```bash
git add -u
git commit -m "<message>" # may fail with "files were modified by this hook"
git add -u # re-stage hook-generated changes
git commit -m "<message>" # succeeds
```
**Commit message format** — HEREDOC, no `Co-Authored-By`:
```bash
git commit -m "$(cat <<'EOF'
Bump Python package versions for <release-label> release
<One paragraph explaining:
- which packages bumped and why (CHANGELOG-driven)
- semver rationale for any released-tier bumps
- whether a beta cohort bump was applied
- whether core floors were raised, and under which policy>
EOF
)"
```
### 9. Push and report
```bash
git push origin bump-py-ver-release-YYMMDD
```
The push output includes a `Create a pull request for '<branch>' on GitHub by visiting: <URL>` line — surface that to the user.
## Pitfalls from past cycles — avoid repeating
- **Scoping from the wrong tag.** A release may have been tagged between bump cycles. Always compute `LAST_RELEASED_TAG` fresh — do NOT reuse the previous compare base. Symptom: dragging already-shipped PRs into the new CHANGELOG section.
- **Bumping packages that had no changes.** This was the old lockstep default. Now: a package without a CHANGELOG entry does not bump. The two exceptions (root follows core, optional beta cohort bump) are explicit choices, not defaults.
- **Touched package missing from CHANGELOG.** The inverse failure mode of the previous pitfall, and the dangerous one. If a PR landed code into `python/packages/foo/` but the new CHANGELOG section has no `**agent-framework-foo**:` line, then `foo` will NOT bump, and the fix sits in `main` but never reaches PyPI. Always run the touched-vs-mentioned reconciliation in step 4a before applying bumps.
- **Directory name vs PyPI name confusion.** Some package dirs use underscores or split names that differ from the published PyPI name (e.g. `foundry_local` → `agent-framework-foundry-local`). When reconciling touched dirs against CHANGELOG mentions, normalize on the PyPI form.
- **Forgetting root when core bumps.** Root `agent-framework` has `agent-framework-core[all]==EXACT_VERSION`. If `core` bumps and root does not, the pin is broken. Always bump them together and update the pin string.
- **Renaming an existing section header.** When drafting a new release, do NOT rewrite an existing `## [X.Y.Z]` header to a new label — that wipes the historical section. Always INSERT a new section above.
- **Forgetting footer reference links.** When a released-tier bump is in play, the `[Unreleased]` line and the new `[X.Y.Z]` link at the bottom MUST be updated. Heading links don't resolve without them.
- **Wrong footer compare base.** The new `[X.Y.Z]` line compares from the PREVIOUS released tag, not from two releases ago.
- **Timezone drift.** For alpha/beta date stamps, follow the `python-package-management` date-stamp rule;
do not infer a local timezone from the user's current shell.
- **`Co-Authored-By` trailer.** Never add it. Rewrite/amend if it slipped in.
- **Stale inventory in this skill.** Always read `python/PACKAGE_STATUS.md` for the live tier map. Do not trust a hardcoded list.
- **Divergent origin vs upstream.** If the release tags from `upstream/main` but `origin/main` is behind, check both — warn if they differ and offer to sync.
- **`--pre` README cleanup on promotion.** When a package is promoted to `released` in this cycle, grep for `pip install agent-framework-<pkg> --pre` in READMEs and drop the `--pre` flag.
- **RC counter inflation.** Do not increment `1.0.0rcN` without a CHANGELOG entry for that package. The counter tracks iterations, not calendar.
## References
- Package lifecycle and versioning source of truth: `python/.github/skills/python-package-management/SKILL.md`
- Lifecycle source of truth: `python/PACKAGE_STATUS.md`
- Validator: `python/scripts/dependencies/validate_dependency_bounds.py` (runs `lowest-direct` and `highest` resolution smoke tests; catches floors/caps that don't match the code)
- Poe task definitions: `python/pyproject.toml` `[tool.poe.tasks]`
+116
View File
@@ -0,0 +1,116 @@
---
name: pull-requests
description: >
Guidance for creating pull requests and handling PR review comments in the
Agent Framework repository. Use this when writing a PR description (filling out
the PR template) or when responding to and resolving review comments on an
existing PR.
---
# Pull Request Workflow
This skill covers two tasks: (1) writing a high-quality PR description, and
(2) handling review comments on an existing PR.
## 1. Writing the PR description
Always follow the repository PR template at
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
exact structure and headings. Fill every section:
### `### Motivation & Context`
Explain *why* the change is needed: the problem it solves and the scenario it
contributes to. Describe the net change relative to `main` — this is implied, so
do **not** spell out "vs main" explicitly.
### `### Description & Review Guide`
Describe the changes, the overall approach, and the design. Answer the three
prompts:
- **What are the major changes?**
- **What is the impact of these changes?**
- **What do you want reviewers to focus on?** — This item is for **human
reviewers only**. Automated/AI reviewers must ignore it and review the entire
change rather than narrowing scope to it.
### `### Related Issue`
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
be closed regardless of how valid the change is. Before opening, confirm there is
no other open PR for the same issue; if there is, explain how this PR differs.
### `### Contribution Checklist`
Check every item that applies. For the breaking-change item:
- Leave **"This is not a breaking change."** checked for the common case.
- If the change **is** breaking, add the `breaking change` label **or** put
`[BREAKING]` in the title prefix, before or after a language prefix such as
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
automatically (see `.github/workflows/label-title-prefix.yml` and
`.github/workflows/label-pr.yml`).
### Do not
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
the checklist already cover validation status.
- Do **not** remove or reorder the template's headings.
### Creating the PR
Open new PRs as **drafts** until they are ready for review. Example:
```bash
gh pr create --repo microsoft/agent-framework --base main \
--head <your-fork-owner>:<branch> --draft \
--title "<concise title>" --body "<body following the template>"
```
## 2. Handling review comments
When a PR receives review comments, follow this sequence — **do not start editing
code before the user has reviewed the plan**:
1. **Review the comments.** Read every review comment and thread on the PR,
including inline code comments and general review summaries.
2. **Make a plan.** Produce a concrete plan describing how each comment will be
addressed (or why it should not be, with reasoning).
3. **Let the user review the plan.** Present the plan and wait for the user's
approval or adjustments before implementing anything.
4. **Implement.** Make the agreed changes.
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
was addressed (or the agreed outcome) — leave none unanswered.
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
comment has actually been addressed.
### Useful commands
List review comments and threads:
```bash
# Inline review comments
gh api repos/{owner}/{repo}/pulls/{pr}/comments
# Review threads with resolution state (GraphQL)
gh api graphql -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
}
}
}
}' -F owner={owner} -F repo={repo} -F pr={pr}
```
Reply to an inline review comment:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
-f body="Addressed in <commit>: <explanation>"
```
Resolve a review thread (needs the thread node id from the GraphQL query above):
```bash
gh api graphql -f query='
mutation($threadId:ID!){
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
}' -F threadId={thread_id}
```
+135
View File
@@ -0,0 +1,135 @@
---
name: python-code-quality
description: >
Code quality checks, linting, formatting, and type checking commands for the
Agent Framework Python codebase. Use this when running checks, fixing lint
errors, or troubleshooting CI failures.
---
# Python Code Quality
## Quick Commands
All commands run from the `python/` directory:
```bash
# 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
#
# Division of labor (see "Type checking architecture" below):
# - Pyright (strict) is the source-code type checker.
# - Pyright (relaxed `basic`), mypy, pyrefly, ty, zuban all check the TESTS;
# pyright/pyrefly/ty also check the SAMPLES (mypy/zuban skip script-style samples).
uv run poe pyright # Pyright (strict) over SOURCE, fan-out across packages
uv run poe pyright -P core
uv run poe pyright -A
uv run poe test-typing # mypy + pyrefly + ty + zuban + pyright over each package's TESTS
uv run poe test-typing -P core
uv run poe test-typing -S # samples (pyrefly + ty + pyright)
uv run poe test-typing -P core --checker mypy # narrow to one checker (repeatable)
uv run poe test-typing -P core --checker pyright # relaxed pyright over the tests
uv run poe mypy # alias: MyPy over the tests only
uv run poe mypy -P core
uv run poe typing # Pyright (source) + the tests checkers
uv run poe typing -P core
uv run poe typing -A
# 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 check -S
uv run poe pyright -S
# Markdown code blocks
uv run poe markdown-code-lint
```
## Pre-commit Hooks (prek)
Prek hooks run automatically on commit. They stay lightweight and only check
changed files.
```bash
# Install hooks
uv run poe prek-install
# Run all hooks manually
uv run prek run -a
# Run on last commit
uv run prek run --last-commit
```
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.
## Type checking architecture
Following the "too many type checkers" approach, type checkers are split by target:
| Target | Checker(s) | Mode | Config |
|--------|-----------|------|--------|
| Source (`agent_framework*`) | **pyright** | strict | `[tool.pyright]` in `pyproject.toml` |
| Tests | pyright, mypy, pyrefly, ty, zuban | relaxed/basic | `pyrightconfig.tests.json`, `[tool.mypy]`, `pyrefly.toml`, `ty` rules |
| Samples | pyright, pyrefly, ty | basic | `pyrightconfig.samples.json`, `pyrefly.samples.toml`, `ty.samples.toml` |
- **Pyright is the only *strict* source-code checker**, and it ALSO runs in a relaxed
`basic` profile over the tests and samples (so the surfaces customers copy from are
validated by every checker, including pyright). MyPy was removed from source; its
`[tool.mypy]` block is now a *relaxed* profile used only for tests/samples.
- The extra checkers run over tests/samples because those exercise the public API the way
users do. The profile is intentionally relaxed (private access allowed, untyped test
bodies allowed) so authors aren't forced into ugly over-annotation.
- **Gating checkers** are `pyright`, `mypy`, `pyrefly`, `ty`, and `zuban` — all five run by
default and gate CI. `zuban` is the strictest of the mypy-compatible pair, so the same
`[tool.mypy]` config yields more findings; suppress zuban-only friction with shared
`# type: ignore[code]`. Suppress relaxed-pyright friction with `# pyright: ignore[rule]`.
- **Samples** add `pyright` to `pyrefly` + `ty` — mypy/zuban can't resolve script-style
sample layouts (numeric-prefixed dirs, duplicate `main.py`), but pyright handles them.
- The strict source-pyright (`[tool.pyright]`) enforces `reportUnnecessaryTypeIgnoreComment`
and excludes tests/samples; the relaxed test/sample pyright configs do not flag unnecessary
ignores.
## Ruff Configuration
- Line length: 120
- Target: Python 3.10+
- Auto-fix enabled
- Rules: ASYNC, B, CPY, D, E, ERA, F, FIX, I, INP, ISC, Q, RET, RSE, RUF, SIM, T20, TD, W, T100, S
- Scripts directory is excluded from checks
## Pyright Configuration
- **Source**: strict mode (`[tool.pyright]`), `reportUnnecessaryTypeIgnoreComment = "error"`,
excludes tests, samples, .venv, packages/devui/frontend.
- **Tests**: relaxed `basic` profile (`pyrightconfig.tests.json`) — private import/usage and
not-required TypedDict access allowed; runs as the `pyright` checker in `test-typing`.
- **Samples**: relaxed `basic` profile (`pyrightconfig.samples.json`, with a py310 variant) —
runs as the `pyright` checker in `test-typing -S`.
## Parallel Execution
The task runner (`scripts/task_runner.py`) executes the cross-product of
(package × task) in parallel using ThreadPoolExecutor. Single items run
in-process with streaming output.
## CI Workflow
CI splits into 4 parallel jobs:
1. **Pre-commit hooks** — lightweight hooks (SKIP=poe-check)
2. **Package checks** — syntax/pyright (source) via check-packages
3. **Samples & markdown**`check -S` plus `markdown-code-lint`
4. **Test Typing** — change-detected mypy/pyrefly/ty over tests (`ci-test-typing`)
+127
View File
@@ -0,0 +1,127 @@
---
name: python-development
description: >
Coding standards, conventions, and patterns for developing Python code in the
Agent Framework repository. Use this when writing or modifying Python source
files in the python/ directory.
---
# Python Development Standards
## File Header
Every `.py` file must start with:
```python
# Copyright (c) Microsoft. All rights reserved.
```
## Type Annotations
- Always specify return types and parameter types
- Use `Type | None` instead of `Optional[Type]`
- Use `from __future__ import annotations` to enable postponed evaluation
- Use suffix `T` for TypeVar names: `ChatResponseT = TypeVar("ChatResponseT", bound=ChatResponse)`
- Use `Mapping` instead of `MutableMapping` for read-only input parameters
- Prefer `# type: ignore[...]` over unnecessary casts, or `isinstance` checks, when these are internally called and executed methods
But make sure the ignore is specific for both mypy and pyright so that we don't miss other mistakes
- Internal private helpers may be used across `agent_framework*` modules when intentional; use a targeted
`# pyright: ignore[reportPrivateUsage]` instead of making the helper public just to satisfy pyright.
- Do not add trivial pass-through or one-line helper functions solely to appease typing. Prefer targeted ignores,
casts, or clearer annotations over adding runtime overhead without a design benefit.
## Function Parameters
- Positional parameters: up to 3 fully expected parameters
- Use keyword-only arguments (after `*`) for optional parameters
- Provide string-based overrides to avoid requiring extra imports:
```python
def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | ChatToolMode) -> Agent:
if isinstance(tool_mode, str):
tool_mode = ChatToolMode(tool_mode)
```
- Avoid shadowing built-ins (use `next_handler` instead of `next`)
- Avoid `**kwargs` unless needed for subclass extensibility; prefer named parameters
## Docstrings
Use Google-style docstrings for all public APIs:
```python
def equal(arg1: str, arg2: str) -> bool:
"""Compares two strings and returns True if they are the same.
Args:
arg1: The first string to compare.
arg2: The second string to compare.
Returns:
True if the strings are the same, False otherwise.
Raises:
ValueError: If one of the strings is empty.
"""
```
- Always document Agent Framework specific exceptions
- Explicitly use `Keyword Args` when applicable
- Only document standard Python exceptions when the condition is non-obvious
## Import Structure
```python
# Core
from agent_framework import Agent, Message, tool
# Components
from agent_framework.observability import enable_sensitive_telemetry
# Connectors (lazy-loaded)
from agent_framework.openai import OpenAIChatClient
from agent_framework.foundry import FoundryChatClient
```
## 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 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__ = ["Agent", "Message", "ChatResponse"]
from ._agents import Agent
from ._types import Message, ChatResponse
```
Special case: the root `agent_framework/__init__.py` uses lazy runtime exports. For root public API changes:
- Add the symbol to `_LAZY_MODULE_EXPORTS` and keep `_LAZY_EXPORTS` derived from it.
- Keep the explicit runtime `__all__` synchronized; it is still required for `from agent_framework import *`.
- Add the same public symbol to `agent_framework/__init__.pyi` so pyright, mypy, and editors see the typed surface.
- Put runtime deprecation behavior in the owning module via that module's `__getattr__`; avoid root-level
special-case branches for individual deprecated exports.
- Identity aliases are appropriate in `.pyi` stubs because they mark re-exported names for type checkers; avoid them
in runtime `.py` modules unless there is a specific compatibility reason.
## Performance Guidelines
- Cache expensive computations (e.g., JSON schema generation)
- Prefer `match/case` on `.type` attribute over `isinstance()` in hot paths
- Avoid redundant serialization — compute once, reuse
## Style
- Line length: 120 characters
- Format only files you changed, not the entire codebase
- Prefer attributes over inheritance when parameters are mostly the same
- Async by default — assume everything is asynchronous
## Naming Conventions for Connectors
- `_prepare_<object>_for_<purpose>` for methods that prepare data for external services
- `_parse_<object>_from_<source>` for methods that process data from external services
+236
View File
@@ -0,0 +1,236 @@
---
name: python-feature-lifecycle
description: >
Guidance for package and feature lifecycle in the Agent Framework Python
codebase, including stage meanings, feature-stage decorators, feature enums,
and how to move APIs from one stage to the next.
---
# Python Feature Lifecycle
## Two lifecycle levels
Agent Framework uses lifecycle at two different levels:
1. **Package lifecycle** — the maturity of the package as a whole
2. **Feature lifecycle** — the maturity of a specific API or feature inside that package
These are related, but they are **not the same thing**.
- The **package stage is the default** for everything in the package.
- **Feature-stage decorators are only for exceptions** when a feature is behind the package's default stage.
- Do **not** decorate every class or function just because the package is experimental or release candidate.
### Important default
If a package is still in **beta / experimental preview**, all public APIs in that package are experimental by default.
- Do **not** add `@experimental(...)` everywhere in that package.
- The package stage already communicates that default.
Once a package moves forward, you can keep individual features behind:
- If a package moves to **release candidate**, a feature may remain **experimental**
- If a package moves to **released / GA**, a feature may remain **experimental** or **release candidate**
That is the main use case for feature-stage decorators.
## The four stages
### 1. Experimental
Use for features that are still unstable and may change or be removed without notice.
Feature-level code pattern:
```python
from ._feature_stage import ExperimentalFeature, experimental
@experimental(feature_id=ExperimentalFeature.MY_FEATURE)
class MyFeature:
...
```
Behavior:
- Adds an experimental warning block to the docstring
- Records feature metadata on the decorated object
- Emits a runtime warning the first time the feature is used (once per feature by default)
Enum setup:
- Add an all-caps member to `ExperimentalFeature`
- Reuse the same feature ID across all APIs that belong to the same conceptual feature
### 2. Release candidate
Use for features that are nearly stable but may still receive small refinements before GA.
Feature-level code pattern:
```python
from ._feature_stage import ReleaseCandidateFeature, release_candidate
@release_candidate(feature_id=ReleaseCandidateFeature.MY_FEATURE)
class MyFeature:
...
```
Behavior:
- Adds a release-candidate note to the docstring
- Records feature metadata on the decorated object
- Does **not** emit the experimental warning
Enum setup:
- Add an all-caps member to `ReleaseCandidateFeature`
### 3. Released
Use for stable GA APIs.
Code pattern:
- **No feature-stage decorator**
- **No entry** in `ExperimentalFeature`
- **No entry** in `ReleaseCandidateFeature`
If a feature is fully released, remove any stage-specific feature annotation.
### 4. Deprecated
Use for APIs that still exist but should not be used for new code.
Code pattern:
```python
import sys
if sys.version_info >= (3, 13):
from warnings import deprecated # type: ignore # pragma: no cover
else:
from typing_extensions import deprecated # type: ignore # pragma: no cover
@deprecated("MyOldFeature is deprecated. Use MyNewFeature instead.")
class MyOldFeature:
...
```
Behavior:
- Uses the repository's version-conditional deprecation import pattern
- Should describe what to use instead
Deprecated APIs should not also carry feature-stage decorators.
## Expected decorators by stage
| Feature stage | Expected annotation |
| --- | --- |
| Experimental | `@experimental(feature_id=ExperimentalFeature.X)` |
| Release candidate | `@release_candidate(feature_id=ReleaseCandidateFeature.X)` |
| Released | No feature-stage decorator |
| Deprecated | `@deprecated("...")` |
## Feature enums
The feature enums are the inventory of currently staged features:
- `ExperimentalFeature`
- `ReleaseCandidateFeature`
Guidance:
- Use one enum member per conceptual feature, not per class
- Ideally, an ADR already defines the overall feature boundary and therefore the feature ID that staged APIs for that feature should reuse
- Keep feature IDs all caps
- Reuse the same member across related APIs for the same feature
- Remove enum members when the feature no longer belongs to that stage
- Treat these enums as **current-stage inventories**, not as a stable consumer introspection API
Minimal consumer guidance:
- Treat `__feature_stage__` and `__feature_id__` as optional staged metadata, not as stable contracts
- Use `getattr(obj, "__feature_stage__", None)` and `getattr(obj, "__feature_id__", None)` rather than direct attribute access
- Treat missing metadata as "no explicit feature-stage annotation"
- For warning filters while a feature is staged, match the literal feature ID string
- Do **not** rely on `ExperimentalFeature.X`, `ReleaseCandidateFeature.X`, or the continued presence of `__feature_id__` after a feature moves stages or is released
For consumers, the enums are also re-exported from `agent_framework`.
For internal implementation code inside `agent_framework`, continue to import the enums and decorators from `._feature_stage`.
## Package stage vs feature stage
Use the following rules:
### Package is experimental / beta
- All public APIs are experimental by default
- Do **not** add feature-stage decorators just to restate that
- Only introduce feature-level annotations later if the package advances first
### Package is release candidate
- All public APIs are RC by default
- Do **not** decorate everything
- Add `@experimental(...)` only for features that are intentionally still behind the package
### Package is released / GA
- All public APIs are released by default
- Add `@experimental(...)` or `@release_candidate(...)` only for features still being held back
## Moving a feature from one stage to the next
### Experimental -> Release candidate
1. Move the feature ID from `ExperimentalFeature` to `ReleaseCandidateFeature`
2. Replace `@experimental(...)` with `@release_candidate(...)`
3. Update any tests or docs that mention the old stage
### Experimental -> Released
1. Remove `@experimental(...)`
2. Remove the feature from `ExperimentalFeature`
3. Do not add a replacement feature-stage decorator
### Release candidate -> Released
1. Remove `@release_candidate(...)`
2. Remove the feature from `ReleaseCandidateFeature`
3. Leave the API undecorated
### Any stage -> Deprecated
1. Remove any feature-stage decorator
2. Remove the feature from the stage enum
3. Add `@deprecated("...")`
4. Update docs/tests to reflect the replacement path
## Promotion guidance
Features do **not** have to pass through every stage.
- It is usually a good idea to move features in order when that reflects reality
- But it is completely acceptable to go **experimental -> released**
- Do **not** force a feature through release candidate if there is no real RC period
Likewise, when a package advances, do not automatically move every feature with it.
- Promote features based on actual readiness
- Keep lagging features explicitly marked only when they are behind the package default
## Practical rules of thumb
- **Package default first, feature exceptions second**
- **Do not decorate everything in preview packages**
- **Do not double-annotate members of an already-staged class**
- **Use enums only for currently staged features**
- **Do not treat stage enums as a compatibility contract**
- **Treat `__feature_stage__` and `__feature_id__` as optional metadata; use `getattr`**
- **Remove stage annotations once a feature is released or deprecated**
+252
View File
@@ -0,0 +1,252 @@
---
name: python-package-management
description: >
Guide for managing packages in the Agent Framework Python monorepo, including
creating new connector packages, versioning, and the lazy-loading pattern.
Use this when adding, modifying, or releasing packages.
---
# Python Package Management
## Monorepo Structure
```
python/
├── pyproject.toml # Root package (agent-framework)
├── packages/
│ ├── core/ # agent-framework-core (main package)
│ ├── foundry/ # agent-framework-foundry
│ ├── anthropic/ # agent-framework-anthropic
│ └── ... # Other connector packages
```
- `agent-framework-core` contains core abstractions and OpenAI/Azure OpenAI built-in
- Provider packages extend core with specific integrations
- Root `agent-framework` depends on `agent-framework-core[all]`
## Dependency Management
Uses [uv](https://github.com/astral-sh/uv) for dependency management and
[poethepoet](https://github.com/nat-n/poethepoet) for task automation.
```bash
# Full setup (venv + install + prek hooks)
uv run poe setup
# 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 exact development dependency-group 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.
- Keep shared tooling and source/type-check support in the root or package `dev` group. Put package-specific test
fixtures in a `test` group, and use a feature-named group for local-only executable dependencies that cannot be
expressed in published runtime metadata.
- Use `upgrade-dev-dependencies` for repo-wide development dependency refreshes; it repins exact dependencies
across development groups, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.
## Lazy Loading Pattern
### Root core API
The root `agent_framework` package is a lazy public API surface:
- Runtime exports live in `packages/core/agent_framework/__init__.py`.
- Typing/editor exports live in `packages/core/agent_framework/__init__.pyi`.
- Add or move root exports in `_LAZY_MODULE_EXPORTS`, keep the explicit runtime `__all__` in sync, and add the same
symbol to the `.pyi` file.
- Keep deprecation behavior in the owning module (for example, a module-level `__getattr__` that warns and returns
the deprecated alias). Do not add one-off deprecated-symbol branches to root `__getattr__`.
- Validate root API changes with `uv run poe syntax -P core`, `uv run poe pyright -P core`, and import smoke tests
for both `from agent_framework import <symbol>` and `from agent_framework import *`.
### Provider namespaces
Provider folders in core use `__getattr__` to lazy load from connector packages:
```python
# In agent_framework/foundry/__init__.py
_IMPORTS: dict[str, tuple[str, str]] = {
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
}
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
import_path, package_name = _IMPORTS[name]
try:
return getattr(importlib.import_module(import_path), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The package {package_name} is required to use `{name}`. "
f"Install it with: pip install {package_name}"
) from exc
```
## Adding a New Connector Package
**Important:** Do not create a new package unless approved by the core team.
Every new package starts as `alpha`.
### Alpha package checklist
1. Create directory under `packages/` (e.g., `packages/my-connector/`)
2. Add the package to `tool.uv.sources` in root `pyproject.toml`
3. Set the package version to the alpha pattern: `1.0.0a<date>`
4. Set the package classifier to `Development Status :: 3 - Alpha`
5. Include samples inside the package (e.g., `packages/my-connector/samples/`)
6. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml`
7. Do **NOT** create lazy loading in core yet
8. Add the package to `python/PACKAGE_STATUS.md` and keep that file updated when packages are added,
removed, renamed, or promoted. If the package exposes individually staged APIs, keep the feature list
there current too.
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 path
Promotion work is not isolated to the package being promoted. If a promotion changes dependency
metadata for downstream packages, also update the dependent packages' own versions so they publish
new metadata alongside the promoted dependency bounds.
Apply the internal package dependency update rules from the versioning section below during
promotions as well as standalone version update work.
#### Alpha -> Beta
Move a package to `beta` when it is stable enough to be part of the main install surface.
1. Update the package version to the beta pattern: `1.0.0b<date>`
2. Update the classifier to `Development Status :: 4 - Beta`
3. Add the package to `[all]` in `packages/core/pyproject.toml`
4. Move samples to the root `samples/` tree and remove package-local samples
5. Create or update the relevant lazy-loading namespace in core when the package belongs under one
6. Update `python/PACKAGE_STATUS.md`
After `alpha`, there should be no samples left inside a package folder.
#### Beta -> RC
Move a package to `rc` when its API is close to the final released shape.
1. Update the package version to the release-candidate pattern: `1.0.0rc<number>`
2. Keep the classifier at `Development Status :: 4 - Beta` because PyPI does not have a separate
release-candidate classifier
3. Keep the package in `core[all]`
4. Keep samples only in the root `samples/` tree
5. Update `python/PACKAGE_STATUS.md` to show the package as `rc`
#### RC -> Released
Move a package to `released` when it no longer carries a prerelease qualifier.
1. Update the package version to the stable pattern: `1.0.0`
2. Update the classifier to `Development Status :: 5 - Production/Stable`
3. Keep the package in `core[all]`
4. Keep samples only in the root `samples/` tree
5. Update `python/PACKAGE_STATUS.md` to show the package as `released`
6. Update all `README.md` files that install that package with
`pip install agent-framework-... --pre` so they use `pip install agent-framework-...` without
the `--pre` suffix
## Versioning
### Internal package dependency updates
- If package A depends on package B within this repository, only update package A's dependency
declaration when the work on package B actually affects package A.
- If package A does not need anything from the package B change, leave package A's dependency
declaration unchanged.
- If package A does need something from the package B change, update package A's dependency
declaration to the version or versioning scheme that matches what package A now requires.
- If package B is promoted to a different lifecycle stage, update package A's dependency
declaration to the new versioning scheme for package B even when the only change is the stage
transition itself.
- Use this guidance both for ordinary version updates and for package promotion work.
- All non-core packages declare a lower bound on `agent-framework-core`
- When core version bumps with breaking changes, update the lower bound in all packages
- Non-core packages version independently; only raise core bound when using new core APIs
- If promoting a package changes a dependent package's published dependency metadata, bump the
dependent package's own version in the correct lifecycle pattern for its current stage
- Lifecycle version patterns:
- `alpha`: `1.0.0a<date>` where `<date>` is the current Pacific (US west coast) `YYMMDD`
- `beta`: `1.0.0b<date>` where `<date>` is the current Pacific (US west coast) `YYMMDD`
- `rc`: `1.0.0rc<number>` where `<number>` increments only when the package has changes
- `released`: `X.Y.Z` using semver per package
- For alpha/beta date stamps, use the current Pacific date as the cutoff, not UTC and not the user's local
timezone. Same-Pacific-day re-cuts use a `.postN` suffix. Honor an explicit user-provided date over this
default.
- Keep the `Development Status` classifier in `pyproject.toml` aligned with the lifecycle stage:
- `alpha` -> `Development Status :: 3 - Alpha`
- `beta` -> `Development Status :: 4 - Beta`
- `rc` -> `Development Status :: 4 - Beta`
- `released` -> `Development Status :: 5 - Production/Stable`
- See the PyPI classifier list for the available classifier values:
`https://pypi.org/classifiers/`
## Installation Options
```bash
pip install agent-framework-core # Core only
pip install agent-framework-core[all] # Core + all connectors
pip install agent-framework # Same as core[all]
pip install agent-framework-foundry # Specific connector (pulls in core)
```
## Maintaining Documentation
When changing a package, check if its `AGENTS.md` needs updates:
- Adding/removing/renaming public classes or functions
- Changing the package's purpose or architecture
- Modifying import paths or usage patterns
Keep `python/PACKAGE_STATUS.md` updated when:
- A package is added, removed, renamed, or promoted between lifecycle stages
- A package starts or stops exposing individually staged experimental or release-candidate APIs
When a package adds, removes, or renames environment variables, update the related documentation in the same
change:
- The package's `README.md` for package-level configuration/env var guidance
- `samples/README.md` if the package is included in `packages/core/pyproject.toml` `[all]` and the env var is
part of the consolidated package env-var inventory
- Any affected sample/package-local `.env.example`, `.env.template`, or sample README files when sample setup
changes alongside the package
+157
View File
@@ -0,0 +1,157 @@
---
name: python-testing
description: >
Guidelines for writing and running tests in the Agent Framework Python
codebase. Use this when creating, modifying, or running tests.
---
# Python Testing
We strive for at least 85% test coverage across the codebase, with a focus on core packages and critical paths. Tests should be fast, reliable, and maintainable.
When adding new code, check that the relevant sections of the codebase are covered by tests, and add new tests as needed. When modifying existing code, update or add tests to cover the changes.
We run tests in two stages, for a PR each commit is tested with unit tests only (using `-m "not integration"`), and the full suite including integration tests is run when merging.
## Running Tests
```bash
# Run tests for all packages in parallel
uv run poe test
# Run tests for a specific workspace package
uv run poe test -P core
# Run all selected tests in a single pytest invocation
uv run poe test -A
# With coverage
uv run poe test -A -C
uv run poe test -P core -C
# Run only unit tests (exclude integration tests)
uv run poe test -A -m "not integration"
# Run only integration tests
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
- **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 aggregate `uv run poe test -A` sweep also uses xdist across the selected packages.
## Test Directory Structure
Test directories must NOT contain `__init__.py` files.
Non-core packages must place tests in a uniquely-named subdirectory:
```
packages/anthropic/
├── tests/
│ └── anthropic/ # Unique subdirectory matching package name
│ ├── conftest.py
│ └── test_client.py
```
Core package can use `tests/` directly with topic subdirectories:
```
packages/core/
├── tests/
│ ├── conftest.py
│ ├── core/
│ │ └── test_agents.py
│ └── openai/
│ └── test_client.py
```
## Fixture Guidelines
- Use `conftest.py` for shared fixtures within a test directory
- Before adding new fixtures, check if existing ones can be reused or extended
- Use descriptive names: `mapper`, `test_request`, `mock_client`
## File Naming
- Files starting with `test_` are test files — do not use this prefix for helpers
- Use `conftest.py` for shared utilities
## Integration Tests
Integration tests require external services (OpenAI, Azure, etc.) and are controlled by three markers:
1. **`@pytest.mark.flaky`** — marks the test as potentially flaky since it depends on external services
2. **`@pytest.mark.integration`** — used for test selection, so integration tests can be included/excluded with `-m integration` / `-m "not integration"`
3. **`@skip_if_..._integration_tests_disabled`** decorator — skips the test when the required API keys or service endpoints are missing
### Adding New Integration Tests
All three markers must be applied to every new integration test:
```python
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_completion() -> None:
...
```
For test files where all tests are integration tests (e.g., Azure Functions, Durable Task), use the module-level `pytestmark` list:
```python
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("01_single_agent"),
pytest.mark.usefixtures("function_app_for_test"),
]
```
### CI Workflow
The merge CI workflow (`python-merge-tests.yml`) splits integration tests into parallel jobs by provider with change-based detection:
- **Unit tests** — always run all non-integration tests
- **OpenAI integration** — runs when `packages/core/agent_framework/openai/` or core infrastructure changes
- **Azure OpenAI integration** — runs when `packages/core/agent_framework/azure/` or core changes
- **Misc integration** — Anthropic, Ollama, MCP tests; runs when their packages or core change
- **Functions integration** — Azure Functions + Durable Task; runs when their packages or core change
- **Foundry integration** — runs when `packages/foundry/` or core changes
Core infrastructure changes (e.g., `_agents.py`, `_types.py`) trigger all integration test jobs. Scheduled and manual runs always execute all jobs.
### Keeping CI Workflows in Sync
Two workflow files define the same set of parallel test jobs:
- **`python-merge-tests.yml`** — runs on PRs, merge queue, schedule, and manual dispatch. Uses path-based change detection to skip unaffected integration jobs.
- **`python-integration-tests.yml`** — called from the manual integration test orchestrator (`integration-tests-manual.yml`). Always runs all jobs (no path filtering).
These workflows must be kept in sync. When you add, remove, or modify a test job, update **both** files. The job structure, pytest commands, and xdist flags should match between them. The only difference is that `python-merge-tests.yml` has path filters and conditional job execution, while `python-integration-tests.yml` does not.
### Updating the CI When Adding Integration Tests for a New Provider
When adding integration tests for a new provider package, you must update **both** `python-merge-tests.yml` and `python-integration-tests.yml`:
1. **Add a path filter** for the new provider in the `paths-filter` job in `python-merge-tests.yml` so the CI knows which file changes should trigger those tests.
2. **Add the test job to both workflow files** — either add them to the existing `python-tests-misc-integration` job, or create a dedicated job if the provider:
- Has a large number of integration tests
- Requires special infrastructure setup (emulators, Docker containers, etc.)
- Has long-running tests that would slow down the misc job
The `python-tests-misc-integration` job is intended for small integration test suites that don't need dedicated infrastructure. When a provider's integration tests grow large or gain special requirements, split them out into their own job (like `python-tests-functions` was split out for Azure Functions + Durable Task).
## Best Practices
- Run only related tests, not the entire suite
- Review existing tests to understand coding style before creating new ones
- Use print statements for debugging, then remove them when done
- Resolve all errors and warnings before committing
+69
View File
@@ -0,0 +1,69 @@
fail_fast: true
exclude: ^scripts/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-toml
name: Check TOML files
files: \.toml$
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- id: check-yaml
name: Check YAML files
files: \.yaml$
- id: check-json
name: Check JSON files
files: \.json$
exclude: ^.*\.vscode\/.*|^demos/samples/chatkit-integration/frontend/(tsconfig.*\.json|package-lock\.json)$
- id: end-of-file-fixer
name: Fix End of File
files: \.py$
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- id: mixed-line-ending
name: Check Mixed Line Endings
files: \.py$
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- id: trailing-whitespace
name: Trim Trailing Whitespace
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- id: check-merge-conflict
name: Check Merge Conflicts
- id: detect-private-key
name: Detect Private Keys
- id: check-added-large-files
name: Check Added Large Files
- id: no-commit-to-branch
name: Protect main branch
args: [--branch, main]
- id: check-ast
name: Check Valid Python Samples
types: ["python"]
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
name: Upgrade Python syntax
args: [--py310-plus]
exclude: ^packages/lab/cookiecutter-agent-framework-lab/
- repo: local
hooks:
- id: poe-check
name: Run checks through Poe
entry: uv run python scripts/workspace_poe_tasks.py prek-check
language: system
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
name: Bandit Security Checks
args: ["-c", "pyproject.toml"]
additional_dependencies: ["bandit[toml]"]
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.11.6
hooks:
# Update the uv lockfile
- id: uv-lock
name: Update uv lockfile
files: pyproject.toml
+34
View File
@@ -0,0 +1,34 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "AG-UI Examples Server",
"type": "debugpy",
"request": "launch",
"module": "agent_framework_ag_ui_examples",
"cwd": "${workspaceFolder}/packages/ag-ui",
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Python Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
}
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"cSpell.languageSettings": [
{
"languageId": "py",
"allowCompoundWords": true,
"locale": "en-US"
}
],
"[python]": {
"editor.codeActionsOnSave": {
"source.organizeImports.ruff": "always",
"source.fixAll.ruff": "always"
},
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": true,
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.analysis.autoFormatStrings": true,
"python.analysis.importFormat": "relative",
"python.analysis.packageIndexDepths": [
{
"name": "agent_framework",
"depth": 2
},
{
"name": "extensions",
"depth": 2
},
{
"name": "openai",
"depth": 2
},
{
"name": "azure",
"depth": 2
}
]
}
+247
View File
@@ -0,0 +1,247 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Run Checks",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"check"
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Syntax",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"syntax",
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Syntax (format only)",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"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",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Mypy",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"mypy",
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Pyright",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"pyright",
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Test",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"test",
],
"problemMatcher": {
"owner": "python",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
}
},
"presentation": {
"panel": "shared"
}
},
{
"label": "Create Venv",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"venv",
"-P",
"${input:py_version}"
],
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Install all dependencies",
"type": "shell",
"command": "uv",
"args": [
"run",
"poe",
"setup",
"-P",
"${input:py_version}"
],
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
],
"inputs": [
{
"type": "pickString",
"options": [
"3.10",
"3.11",
"3.12",
"3.13",
"3.14"
],
"id": "py_version",
"description": "Python version",
"default": "3.13"
}
]
}
+114
View File
@@ -0,0 +1,114 @@
# AGENTS.md
Instructions for AI coding agents working in the Python codebase.
**Key Documentation:**
- [DEV_SETUP.md](DEV_SETUP.md) - Development environment setup and available poe tasks
- [CODING_STANDARD.md](CODING_STANDARD.md) - Coding standards, docstring format, and performance guidelines
- [samples/SAMPLE_GUIDELINES.md](samples/SAMPLE_GUIDELINES.md) - Sample structure and guidelines
**Agent Skills** (`.github/skills/`) — detailed, task-specific instructions loaded on demand:
- `python-development` — coding standards, type annotations, docstrings, logging, performance
- `python-testing` — test structure, fixtures, async mode, running tests
- `python-code-quality` — linting, formatting, type checking, prek hooks, CI workflow
- `python-feature-lifecycle` — package vs feature lifecycle stages, decorators, enums, and promotion guidance
- `python-package-management` — monorepo structure, lazy loading, versioning, new packages
- `pull-requests` — writing PR descriptions (template) and handling/resolving PR review comments
- `agent-framework-py-release` — Python release PR workflow, CHANGELOG-driven package bumps, lifecycle version rules, and dependency-floor validation
## Maintaining Documentation
When making changes to a package, check if the following need updates:
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
At the end of every run, re-read `AGENTS.md` and the relevant skill files and
update any guidance that the conversation revealed to be out of date,
incomplete, or misleading (renamed files, changed commands, new conventions
the user confirmed, etc.). **Before adding a new principle or rule, ask the
user whether they want it captured as a durable principle** — do not invent
team norms from a single conversation without explicit confirmation.
## Terminology
- **Avoid "GA" for Agent Framework code.** Reserve *GA* for hosted services
(e.g. "the Foundry service is GA"). For Agent Framework packages, features,
and APIs use **"released"** or **"stable"** depending on context — these
match the feature-lifecycle stages documented in the
`python-feature-lifecycle` skill.
## Pull Request Description Guidance
When preparing a PR description:
- Follow the repository PR template at `.github/pull_request_template.md` and keep its structure/headings.
- Describe the net change relative to `main` (this is implied; do not call it out explicitly as "vs main").
- Do not add ad-hoc validation sections (for example, "Validation" or "Tests run"); CI/CD and the template checklist cover validation status.
## Quick Reference
Run `uv run poe` from the `python/` directory to see available commands. See [DEV_SETUP.md](DEV_SETUP.md) for detailed usage.
## Project Structure
```
python/
├── packages/
│ ├── core/ # agent-framework-core (main package)
│ │ ├── agent_framework/ # Public API exports
│ │ └── tests/
│ ├── foundry/ # agent-framework-foundry
│ ├── anthropic/ # agent-framework-anthropic
│ ├── ollama/ # agent-framework-ollama
│ └── ... # Other provider packages
├── samples/ # Sample code and examples
├── .github/skills/ # Agent skills for Copilot
└── tests/ # Integration tests
```
### Package Relationships
- `agent-framework-core` contains core abstractions and OpenAI/Azure OpenAI built-in
- Provider packages (`foundry`, `anthropic`, etc.) extend core with specific integrations
- The root `agent_framework` public API is lazy-loaded from `packages/core/agent_framework/__init__.py` and
described for type checkers in `packages/core/agent_framework/__init__.pyi`; keep both plus `__all__` in sync.
- Core uses lazy loading via `__getattr__` in provider folders (e.g., `agent_framework/azure/`)
## Package Documentation
### Core
- [core](packages/core/AGENTS.md) - Core abstractions, types, and built-in OpenAI/Azure OpenAI support
### LLM Providers
- [anthropic](packages/anthropic/AGENTS.md) - Anthropic Claude API
- [bedrock](packages/bedrock/AGENTS.md) - AWS Bedrock
- [claude](packages/claude/AGENTS.md) - Claude Agent SDK
- [foundry_local](packages/foundry_local/AGENTS.md) - Azure AI Foundry Local
- [ollama](packages/ollama/AGENTS.md) - Local Ollama inference
### Azure Integrations
- [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations
- [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider
- [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG
- [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider
- [azurefunctions](packages/azurefunctions/AGENTS.md) - Azure Functions hosting
### Protocols & UI
- [a2a](packages/a2a/AGENTS.md) - Agent-to-Agent protocol
- [ag-ui](packages/ag-ui/AGENTS.md) - AG-UI protocol
- [chatkit](packages/chatkit/AGENTS.md) - OpenAI ChatKit integration
- [devui](packages/devui/AGENTS.md) - Developer UI for testing
### Storage & Memory
- [mem0](packages/mem0/AGENTS.md) - Mem0 memory integration
- [redis](packages/redis/AGENTS.md) - Redis storage
### Infrastructure
- [copilotstudio](packages/copilotstudio/AGENTS.md) - Microsoft Copilot Studio
- [declarative](packages/declarative/AGENTS.md) - YAML/JSON agent definitions
- [durabletask](packages/durabletask/AGENTS.md) - Durable execution
- [github_copilot](packages/github_copilot/AGENTS.md) - GitHub Copilot extensions
- [purview](packages/purview/AGENTS.md) - Data governance
### Experimental
- [lab](packages/lab/AGENTS.md) - Experimental features
- [monty](packages/monty/AGENTS.md) - Monty-backed CodeAct integrations (alpha)
+1391
View File
File diff suppressed because it is too large Load Diff
+770
View File
@@ -0,0 +1,770 @@
# Coding Standards
This document describes the coding standards and conventions for the Agent Framework project.
## Code Style and Formatting
We use [ruff](https://github.com/astral-sh/ruff) for both linting and formatting with the following configuration:
- **Line length**: 120 characters
- **Target Python version**: 3.10+
- **Google-style docstrings**: All public functions, classes, and modules should have docstrings following Google conventions
### Module Docstrings
Public modules must include a module-level docstring, including `__init__.py` files.
- Namespace-style `__init__.py` modules (for example under `agent_framework/<provider>/`) should use a structured
docstring that includes:
- A one-line summary of the namespace
- A short "This module lazily re-exports objects from:" section that lists only pip install package names
(for example `agent-framework-a2a`)
- A short "Supported classes:" (or "Supported classes and functions:") section
- The main `agent_framework/__init__.py` should include a concise background-oriented docstring rather than a long
per-symbol list.
- Core modules with broad surface area, including `agent_framework/exceptions.py` and
`agent_framework/observability.py`, should always have explicit module docstrings.
## Type Annotations
We use typing as a helper, it is not a goal in and of itself, so be pragmatic about where and when to strictly type, versus when to use a targetted cast or ignore.
In general, the public interfaces of our classes, are important to get right, internally it is okay to have loosely typed code, as long as tests cover the code itself.
This includes making a conscious choice when to program defensively, you can always do `getattr(item, 'attribute')` but that might end up causing you issues down the road
because the type of `item` in this case, should have that attribute and if it doesn't it points to a larger issue, so if the type is expected to have that attribute, you should
use `item.attribute` to ensure it fails at that point, rather then somewhere downstream where a value is expected but none was found.
### Future Annotations
> **Note:** This convention is being adopted. See [#3578](https://github.com/microsoft/agent-framework/issues/3578) for progress.
Use `from __future__ import annotations` at the top of files to enable postponed evaluation of annotations. This prevents the need for string-based type hints for forward references:
```python
# ✅ Preferred - use future annotations
from __future__ import annotations
class Agent:
def create_child(self) -> Agent: # No quotes needed
...
# ❌ Avoid - string-based type hints
class Agent:
def create_child(self) -> "Agent": # Requires quotes without future annotations
...
```
### TypeVar Naming Convention
> **Note:** This convention is being adopted. See [#3594](https://github.com/microsoft/agent-framework/issues/3594) for progress.
Use the suffix `T` for TypeVar names instead of a prefix:
```python
# ✅ Preferred - suffix T
ChatResponseT = TypeVar("ChatResponseT", bound=ChatResponse)
AgentT = TypeVar("AgentT", bound=Agent)
# ❌ Avoid - prefix T
TChatResponse = TypeVar("TChatResponse", bound=ChatResponse)
TAgent = TypeVar("TAgent", bound=Agent)
```
### Mapping Types
> **Note:** This convention is being adopted. See [#3577](https://github.com/microsoft/agent-framework/issues/3577) for progress.
Use `Mapping` instead of `MutableMapping` for input parameters when mutation is not required:
```python
# ✅ Preferred - Mapping for read-only access
def process_config(config: Mapping[str, Any]) -> None:
...
# ❌ Avoid - MutableMapping when mutation isn't needed
def process_config(config: MutableMapping[str, Any]) -> None:
...
```
### Typing Ignore and Cast Policy
Use typing as a helper first and suppressions as a last resort:
- **Prefer explicit typing before suppression**: Start with clearer type annotations, helper types, overloads,
protocols, or refactoring dynamic code into typed helpers. Prioritize performance over completeness of typing, but make a good-faith effort to reduce uncertainty with typing before ignoring. Prefer to use a cast over a typeguard function since that does add overhead.
- **Avoid redundant casts**: Do not add `cast(...)` if the type already matches; casts should be reserved for
unavoidable narrowing where the runtime contract is known.
- **Avoid multiple assignments**: Avoid assigning multiple variables just to get typing to pass, that has performance impact while typing should not have that.
- **Source vs tests/samples**: Source code (`agent_framework*`) is checked **by pyright in strict mode** — use
`# pyright: ignore[...]` there, never `# type: ignore` (strict pyright flags unnecessary ignores as errors). Tests
and samples are checked by pyright (relaxed `basic`), mypy, pyrefly, ty (and zuban on tests) in a relaxed/basic
profile; prefer real fixes (`isinstance`, `cast`, annotations, asserts for Optional access) over per-line ignores,
and keep test/sample bodies readable rather than over-annotated. When a relaxed-pyright suppression is genuinely
needed in tests/samples, use `# pyright: ignore[rule]`; the relaxed test/sample configs do not flag unnecessary
ignores, so combine with a mypy/zuban `# type: ignore[code]` on the same line only where both are required.
- **Line-level pyright ignores only**: If suppression is still required in source, use a line-level rule-specific ignore
(`# pyright: ignore[reportGeneralTypeIssues]`), file-level is allowed if there is a compelling reason for it, that should be documented right beneath the ignore.
Never change the global suppression flags unless the dev team okays it.
- **Private usage boundary**: Accessing private members across `agent_framework*` packages can be acceptable for this
codebase, but private member usage for non-Agent Framework dependencies should remain flagged. Do not make an
internal helper public merely to satisfy pyright private-usage checks inside the package; use a targeted
`# pyright: ignore[reportPrivateUsage]` when the internal dependency is intentional.
- **Avoid typing-only wrappers**: Do not introduce trivial pass-through functions solely to satisfy typing or private
usage checks. Prefer a targeted ignore, cast, or clearer annotation over an extra one-line function that adds runtime
overhead without improving the design.
## Function Parameter Guidelines
To make the code easier to use and maintain:
- **Positional parameters**: Only use for up to 3 fully expected parameters (this is not a hard rule, but a guideline there are instances where this does make sense to exceed)
- **Keyword-only parameters**: Arguments after `*` in function signatures are keyword-only; prefer these for optional parameters
- **Avoid additional imports**: Do not require the user to import additional modules to use the function, so provide string based overrides when applicable, for instance:
```python
def create_agent(name: str, tool_mode: ChatToolMode) -> Agent:
# Implementation here
```
Should be:
```python
def create_agent(name: str, tool_mode: Literal['auto', 'required', 'none'] | ChatToolMode) -> Agent:
# Implementation here
if isinstance(tool_mode, str):
tool_mode = ChatToolMode(tool_mode)
```
- **Avoid shadowing built-ins**: Do not use parameter names that shadow Python built-ins (e.g., use `next_handler` instead of `next`). See [#3583](https://github.com/microsoft/agent-framework/issues/3583) for progress.
### Using `**kwargs`
> **Note:** This convention is being adopted. See [#3642](https://github.com/microsoft/agent-framework/issues/3642) for progress.
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
## Method Naming Inside Connectors
When naming methods inside connectors, we have a loose preference for using the following conventions:
- Use `_prepare_<object>_for_<purpose>` as a prefix for methods that prepare data for sending to the external service.
- Use `_parse_<object>_from_<source>` as a prefix for methods that process data received from the external service.
This is not a strict rule, but a guideline to help maintain consistency across the codebase.
## Implementation Decisions
### Asynchronous Programming
It's important to note that most of this library is written with asynchronous in mind. The
developer should always assume everything is asynchronous. One can use the function signature
with either `async def` or `def` to understand if something is asynchronous or not.
### Attributes vs Inheritance
Prefer attributes over inheritance when parameters are mostly the same:
```python
# ✅ Preferred - using attributes
from agent_framework import Message
user_msg = Message("user", ["Hello, world!"])
asst_msg = Message("assistant", ["Hello, world!"])
# ❌ Not preferred - unnecessary inheritance
class UserMessage(Message):
pass
class AssistantMessage(Message):
pass
user_msg = UserMessage("user", ["Hello, world!"])
asst_msg = AssistantMessage("assistant", ["Hello, world!"])
```
### Import Structure
The package follows a flat import structure:
- **Core**: Import directly from `agent_framework`
```python
from agent_framework import Agent, tool
```
- **Components**: Import from `agent_framework.<component>`
```python
from agent_framework.observability import enable_sensitive_telemetry, configure_otel_providers
```
- **Connectors**: Import from `agent_framework.<vendor/platform>`
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework.foundry import FoundryChatClient
```
## Exception Hierarchy
The Agent Framework defines a structured exception hierarchy rooted at `AgentFrameworkException`. Every AF-specific
exception inherits from this base, so callers can catch `AgentFrameworkException` as a broad fallback. The hierarchy
is organized into domain-specific L1 branches, each with a consistent set of leaf exceptions where applicable.
### Design Principles
- **Domain-scoped branches**: Exceptions are grouped by the subsystem that raises them (agent, chat client,
integration, workflow, content, tool, middleware), not by HTTP status code or generic error category.
- **Consistent suberror pattern**: The `AgentException`, `ChatClientException`, and `IntegrationException` branches
share a parallel set of leaf exceptions (`InvalidAuth`, `InvalidRequest`, `InvalidResponse`, `ContentFilter`) so
that callers can handle the same failure mode uniformly across domains.
- **Built-ins for validation**: Configuration/parameter validation errors use Python built-in exceptions
(`ValueError`, `TypeError`, `RuntimeError`) rather than AF-specific classes. AF exceptions are reserved for
domain-level failures that callers may want to catch and handle distinctly from programming errors.
- **No compatibility aliases**: When exceptions are renamed or removed, the old names are not kept as aliases.
This is a deliberate trade-off for hierarchy clarity over backward compatibility.
- **Suffix convention**: L1 branch classes use `...Exception` (e.g., `AgentException`). Leaf classes may use
either `...Exception` or `...Error` depending on the domain convention (e.g., `ContentError`,
`WorkflowValidationError`). Within a branch, the suffix is consistent.
### Full Hierarchy
```
AgentFrameworkException # Base for all AF exceptions
├── AgentException # Agent-scoped failures
│ ├── AgentInvalidAuthException # Agent auth failures
│ ├── AgentInvalidRequestException # Invalid request to agent (e.g., agent not found, bad input)
│ ├── AgentInvalidResponseException # Invalid/unexpected response from agent
│ └── AgentContentFilterException # Agent content filter triggered
├── ChatClientException # Chat client lifecycle and communication failures
│ ├── ChatClientInvalidAuthException # Chat client auth failures
│ ├── ChatClientInvalidRequestException # Invalid request to chat client
│ ├── ChatClientInvalidResponseException # Invalid/unexpected response from chat client
│ └── ChatClientContentFilterException # Chat client content filter triggered
├── IntegrationException # External service/dependency integration failures
│ ├── IntegrationInitializationError # Wrapped dependency lifecycle failure during setup
│ ├── IntegrationInvalidAuthException # Integration auth failures (e.g., 401/403)
│ ├── IntegrationInvalidRequestException # Invalid request to integration
│ ├── IntegrationInvalidResponseException # Invalid/unexpected response from integration
│ └── IntegrationContentFilterException # Integration content filter triggered
├── ContentError # Content processing/validation failures
│ └── AdditionItemMismatch # Type mismatch when merging content items
├── WorkflowException # Workflow engine failures
│ ├── WorkflowRunnerException # Runtime execution failures
│ │ ├── WorkflowConvergenceException # Runner exceeded max iterations
│ │ └── WorkflowCheckpointException # Checkpoint save/restore/decode failures
│ ├── WorkflowValidationError # Graph validation errors
│ │ ├── EdgeDuplicationError # Duplicate edge in workflow graph
│ │ ├── TypeCompatibilityError # Type mismatch between connected executors
│ │ └── GraphConnectivityError # Graph connectivity issues
│ ├── WorkflowActionError # User-level error from declarative ThrowException action
│ └── DeclarativeWorkflowError # Declarative workflow definition/YAML errors
├── ToolException # Tool-related failures
│ └── ToolExecutionException # Failure during tool execution
├── MiddlewareException # Middleware failures
│ └── MiddlewareTermination # Control-flow: early middleware termination
└── SettingNotFoundError # Required setting not resolved from any source
```
### When to Use AF Exceptions vs Built-ins
| Scenario | Exception to use |
|---|---|
| Missing or invalid constructor argument (e.g., `api_key` is `None`) | `ValueError` or `TypeError` |
| Object in wrong state (e.g., client not initialized) | `RuntimeError` |
| External service returns 401/403 | `IntegrationInvalidAuthException` (or `ChatClient`/`Agent` variant) |
| External service returns unexpected response | `IntegrationInvalidResponseException` (or variant) |
| Content filter blocks a request | `IntegrationContentFilterException` (or variant) |
| Request validation fails before sending to service | `IntegrationInvalidRequestException` (or variant) |
| Agent not found in registry | `AgentInvalidRequestException` |
| Agent returned no/bad response | `AgentInvalidResponseException` |
| Workflow runner exceeds max iterations | `WorkflowConvergenceException` |
| Checkpoint serialization/deserialization failure | `WorkflowCheckpointException` |
| Workflow graph has invalid structure | `WorkflowValidationError` (or specific subclass) |
| Declarative YAML definition error | `DeclarativeWorkflowError` |
| Tool execution failure | `ToolExecutionException` |
| Content merge type mismatch | `AdditionItemMismatch` |
### Choosing Between Agent, ChatClient, and Integration Branches
- **`AgentException`**: The failure is scoped to agent-level logic — agent lookup, agent response handling,
agent content filtering. Use when the agent itself is the source of the problem.
- **`ChatClientException`**: The failure is scoped to the chat client (the LLM provider connection) — auth with
the LLM provider, request/response format issues specific to the chat protocol, chat-level content filtering.
- **`IntegrationException`**: The failure is in a non-chat external dependency — search services, vector stores,
Purview, custom APIs, or any service that is not the primary LLM chat provider.
When in doubt: if the code is in a chat client constructor or method, use `ChatClient*`. If it's in an agent
method, use `Agent*`. If it's talking to an external service that isn't the chat LLM, use `Integration*`.
## Package Structure
The project uses a monorepo structure with separate packages for each connector/extension:
```plaintext
python/
├── pyproject.toml # Root package (agent-framework) depends on agent-framework-core[all]
├── samples/ # Sample code and examples
├── packages/
│ ├── core/ # agent-framework-core - Core abstractions and implementations
│ │ ├── pyproject.toml # Defines [all] extra that includes all connector packages
│ │ ├── tests/ # Tests for core package
│ │ └── agent_framework/
│ │ ├── __init__.py # Lazy runtime public API exports
│ │ ├── __init__.pyi # Public API typing surface for lazy root exports
│ │ ├── _agents.py # Agent implementations
│ │ ├── _clients.py # Chat client protocols and base classes
│ │ ├── _tools.py # Tool definitions
│ │ ├── _types.py # Type definitions
│ │ │ # Provider folders - lazy load from connector packages
│ │ ├── openai/ # OpenAI clients (built into core)
│ │ ├── azure/ # Lazy loads from azure-ai, azure-ai-search, azurefunctions
│ │ ├── anthropic/ # Lazy loads from agent-framework-anthropic
│ │ ├── ollama/ # Lazy loads from agent-framework-ollama
│ │ ├── a2a/ # Lazy loads from agent-framework-a2a
│ │ ├── ag_ui/ # Lazy loads from agent-framework-ag-ui
│ │ ├── chatkit/ # Lazy loads from agent-framework-chatkit
│ │ ├── declarative/ # Lazy loads from agent-framework-declarative
│ │ ├── devui/ # Lazy loads from agent-framework-devui
│ │ ├── mem0/ # Lazy loads from agent-framework-mem0
│ │ └── redis/ # Lazy loads from agent-framework-redis
│ │
│ ├── foundry/ # agent-framework-foundry
│ │ ├── pyproject.toml
│ │ ├── tests/
│ │ └── agent_framework_foundry/
│ │ ├── __init__.py # Public exports
│ │ ├── _chat_client.py # FoundryChatClient implementation
│ │ ├── _agent.py # FoundryAgent implementation
│ │ ├── _embedding_client.py # FoundryEmbeddingClient implementation
│ │ ├── _memory_provider.py # Foundry memory implementation
│ │ └── py.typed # PEP 561 marker
│ ├── anthropic/ # agent-framework-anthropic
│ ├── bedrock/ # agent-framework-bedrock
│ ├── ollama/ # agent-framework-ollama
│ └── ... # Other connector packages
```
### Lazy Loading Pattern
The root `agent_framework` package is a lazy public API surface. When adding, removing, or moving a root export:
- Add the symbol to `_LAZY_MODULE_EXPORTS` in `agent_framework/__init__.py`.
- Keep `_LAZY_EXPORTS` derived from `_LAZY_MODULE_EXPORTS`.
- Keep the explicit runtime `__all__` synchronized; it is required for `from agent_framework import *`.
- Add the same public symbol to `agent_framework/__init__.pyi` so type checkers and editors see the typed surface.
- Put runtime deprecation behavior in the owning module using that module's `__getattr__`. Do not add one-off
deprecated-symbol branches to root `agent_framework.__getattr__`.
- Validate root API changes with `uv run poe syntax -P core`, `uv run poe pyright -P core`, and import smoke tests
for both `from agent_framework import <symbol>` and `from agent_framework import *`.
Provider folders in the core package use `__getattr__` to lazy load classes from their respective connector packages. This allows users to import from a consistent location while only loading dependencies when needed:
```python
# In agent_framework/foundry/__init__.py
_IMPORTS: dict[str, tuple[str, str]] = {
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
# ...
}
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
import_path, package_name = _IMPORTS[name]
try:
return getattr(importlib.import_module(import_path), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The package {package_name} is required to use `{name}`. "
f"Install it with: pip install {package_name}"
) from exc
```
### Adding a New Connector Package
**Important:** Do not create a new package unless there is an issue that has been reviewed and approved by the core team.
#### Initial Release (Preview Phase)
For the first release of a new connector package:
1. Create a new directory under `packages/` (e.g., `packages/my-connector/`)
2. Add the package to `tool.uv.sources` in the root `pyproject.toml`
3. Include samples inside the package itself (e.g., `packages/my-connector/samples/`)
4. **Do NOT** add the package to the `[all]` extra in `packages/core/pyproject.toml`
5. **Do NOT** create lazy loading in core yet
#### Promotion to Stable
After the package has been released and gained a measure of confidence:
1. Move samples from the package to the root `samples/` folder
2. Add the package to the `[all]` extra in `packages/core/pyproject.toml`
3. Create a provider folder in `agent_framework/` with lazy loading `__init__.py`
### Versioning and Core Dependency
All non-core packages declare a lower bound on `agent-framework-core` (e.g., `"agent-framework-core>=1.0.0b260130"`). Follow these rules when bumping versions:
- **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:
```bash
# Install core only
pip install agent-framework-core
# Install core with all connectors
pip install agent-framework-core[all]
# or (equivalently):
pip install agent-framework
# Install specific connector (pulls in core as dependency)
pip install agent-framework-foundry
```
## Documentation
Each file should have a single first line containing: # Copyright (c) Microsoft. All rights reserved.
We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods.
They are currently not checked for private functions (functions starting with '_').
When a change adds, removes, or renames a sample-facing environment variable in repo-level samples or
package-local sample docs for a package included by `agent-framework-core[all]`, update the consolidated
inventory in `samples/README.md` in the same change.
They should contain:
- Single line explaining what the function does, ending with a period.
- If necessary to further explain the logic a newline follows the first line and then the explanation is given.
- The following three sections are optional, and if used should be separated by a single empty line.
- Arguments are then specified after a header called `Args:`, with each argument being specified in the following format:
- `arg_name`: Explanation of the argument.
- if a longer explanation is needed for a argument, it should be placed on the next line, indented by 4 spaces.
- Type and default values do not have to be specified, they will be pulled from the definition.
- Returns are specified after a header called `Returns:` or `Yields:`, with the return type and explanation of the return value.
- Keyword arguments are specified after a header called `Keyword Args:`, with each argument being specified in the same format as `Args:`.
- A header for exceptions can be added, called `Raises:`, following these guidelines:
- **Always document** Agent Framework specific exceptions (e.g., `AgentInvalidRequestException`, `IntegrationInvalidAuthException`)
- **Only document** standard Python exceptions (TypeError, ValueError, KeyError, etc.) when the condition is non-obvious or provides value to API users
- Format: `ExceptionType`: Explanation of the exception.
- If a longer explanation is needed, it should be placed on the next line, indented by 4 spaces.
- Code examples can be added using the `Examples:` header followed by `.. code-block:: python` directive.
Putting them all together, gives you at minimum this:
```python
def equal(arg1: str, arg2: str) -> bool:
"""Compares two strings and returns True if they are the same."""
...
```
Or a complete version of this:
```python
def equal(arg1: str, arg2: str) -> bool:
"""Compares two strings and returns True if they are the same.
Here is extra explanation of the logic involved.
Args:
arg1: The first string to compare.
arg2: The second string to compare.
Returns:
True if the strings are the same, False otherwise.
"""
```
A more complete example with keyword arguments and code samples:
```python
def create_client(
model: str | None = None,
*,
timeout: float | None = None,
env_file_path: str | None = None,
**kwargs: Any,
) -> Client:
"""Create a new client with the specified configuration.
Args:
model: The model ID to use. If not provided,
it will be loaded from settings.
Keyword Args:
timeout: Optional timeout for requests.
env_file_path: If provided, settings are read from this file.
kwargs: Additional keyword arguments passed to the underlying client.
Returns:
A configured client instance.
Raises:
ValueError: If the model is invalid.
Examples:
.. code-block:: python
# Create a client with default settings:
client = create_client(model="gpt-4o")
# Or load from environment:
client = create_client(env_file_path=".env")
"""
...
```
Use Google-style docstrings for all public APIs:
```python
def create_agent(name: str, client: SupportsChatGetResponse) -> Agent:
"""Create a new agent with the specified configuration.
Args:
name: The name of the agent.
client: The chat client to use for communication.
Returns:
True if the strings are the same, False otherwise.
Raises:
ValueError: If one of the strings is empty.
"""
...
```
If in doubt, use the link above to read much more considerations of what to do and when, or use common sense.
## Public API and Exports
### Explicit Exports
**All wildcard imports (`from ... import *`) are prohibited** in production code, including both `.py` and `.pyi` files. Always use explicit import lists to maintain clarity and avoid namespace pollution.
Do not use ``__all__`` in internal modules. Define it in the ``__init__`` file of the level you want to expose.
If a non-``__init__`` module is intentionally part of the public API surface (for example, ``observability.py``),
it should define ``__all__`` as well.
Also avoid identity alias imports in ``__init__`` files. Use ``from ._module import Symbol`` instead of
``from ._module import Symbol as Symbol``.
Exception: `.pyi` stubs that describe re-exported public APIs should use identity aliases (for example,
`from ._agents import Agent as Agent`) so type checkers recognize the symbol as exported. This applies to the
root `agent_framework/__init__.pyi`, which mirrors the lazy runtime exports from `agent_framework/__init__.py`.
```python
# ✅ Preferred - explicit __all__ and named imports
from ._agents import Agent
from ._types import Message, ChatResponse
# ✅ For many exports, use parenthesized multi-line imports
from ._types import (
AgentResponse,
ChatResponse,
Message,
ResponseStream,
)
__all__ = [
"Agent",
"AgentResponse",
"ChatResponse",
"Message",
"ResponseStream",
]
# ❌ Prohibited pattern: wildcard/star imports (do not use)
# from ._agents import *
# from ._types import *
# ❌ Prohibited pattern: identity alias imports (do not use)
# from ._agents import Agent as Agent
```
**Rationale:**
- **Clarity**: Explicit imports make it clear exactly what is being exported and used
- **IDE Support**: Enables better autocomplete, go-to-definition, and refactoring
- **Type Checking**: Improves static analysis and type checker accuracy
- **Maintenance**: Makes it easier to track symbol usage and detect breaking changes
- **Performance**: Avoids unnecessary symbol resolution during module import
## Performance considerations
### Cache Expensive Computations
Think about caching where appropriate. Cache the results of expensive operations that are called repeatedly with the same inputs:
```python
# ✅ Preferred - cache expensive computations
class FunctionTool:
def __init__(self, ...):
self._cached_parameters: dict[str, Any] | None = None
def parameters(self) -> dict[str, Any]:
"""Return the JSON schema for the function's parameters.
The result is cached after the first call for performance.
"""
if self._cached_parameters is None:
self._cached_parameters = self.input_model.model_json_schema()
return self._cached_parameters
# ❌ Avoid - recalculating every time
def parameters(self) -> dict[str, Any]:
return self.input_model.model_json_schema()
```
### Prefer Attribute Access Over isinstance()
When checking types in hot paths, prefer checking a `type` attribute (fast string comparison) over `isinstance()` (slower due to method resolution order traversal):
```python
# ✅ Preferred - use match/case with type attribute (faster)
match content.type:
case "function_call":
# handle function call
case "usage":
# handle usage
case _:
# handle other types
# ❌ Avoid in hot paths - isinstance() is slower
if isinstance(content, FunctionCallContent):
# handle function call
elif isinstance(content, UsageContent):
# handle usage
```
For inline conditionals:
```python
# ✅ Preferred - type attribute comparison
result = value if content.type == "function_call" else other
# ❌ Avoid - isinstance() in hot paths
result = value if isinstance(content, FunctionCallContent) else other
```
### Avoid Redundant Serialization
When the same data needs to be used in multiple places, compute it once and reuse it:
```python
# ✅ Preferred - reuse computed representation
otel_message = _to_otel_message(message)
otel_messages.append(otel_message)
logger.info(otel_message, extra={...})
# ❌ Avoid - computing the same thing twice
otel_messages.append(_to_otel_message(message)) # this already serializes
message_data = message.to_dict(exclude_none=True) # and this does so again!
logger.info(message_data, extra={...})
```
## Test Organization
### Test Directory Structure
Test folders require specific organization to avoid pytest conflicts when running tests across packages:
1. **No `__init__.py` in test folders**: Test directories should NOT contain `__init__.py` files. This can cause import conflicts when pytest collects tests across multiple packages.
2. **File naming**: Files starting with `test_` are treated as test files by pytest. Do not use this prefix for helper modules or utilities. If you need shared test utilities, put them in `conftest.py` or a file with a different name pattern (e.g., `helpers.py`, `fixtures.py`).
3. **Package-specific conftest location**: The `tests/conftest.py` path is reserved for the core package (`packages/core/tests/conftest.py`). Other packages must place their tests in a uniquely-named subdirectory:
```plaintext
# ✅ Correct structure for non-core packages
packages/devui/
├── tests/
│ └── devui/ # Unique subdirectory matching package name
│ ├── conftest.py # Package-specific fixtures
│ ├── test_server.py
│ └── test_mapper.py
packages/anthropic/
├── tests/
│ └── anthropic/ # Unique subdirectory
│ ├── conftest.py
│ └── test_client.py
# ❌ Incorrect - will conflict with core package
packages/devui/
├── tests/
│ ├── conftest.py # Conflicts when running all tests
│ ├── test_server.py
│ └── test_helpers.py # Bad name - looks like a test file
# ✅ Core package can use tests/ directly
packages/core/
├── tests/
│ ├── conftest.py # Core's conftest.py
│ ├── core/
│ │ └── test_agents.py
│ └── openai/
│ └── test_client.py
```
4. **Keep the `tests/` folder**: Even when using a subdirectory, keep the `tests/` folder at the package root. Some test discovery commands and tooling rely on this convention.
### Fixture Guidelines
- Use `conftest.py` for shared fixtures within a test directory
- Factory functions with parameters should be regular functions, not fixtures (fixtures can't accept arguments)
- Import factory functions explicitly: `from conftest import create_test_request`
- Fixtures should use simple names that describe what they provide: `mapper`, `test_request`, `mock_client`
### Integration Test Markers
New integration tests that call external services must have all three markers:
```python
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled
async def test_chat_completion() -> None:
...
```
- `@pytest.mark.flaky` — marks the test as potentially flaky since it depends on external services
- `@pytest.mark.integration` — enables selecting/excluding integration tests with `-m integration` / `-m "not integration"`
- `@skip_if_..._integration_tests_disabled` — skips the test when required API keys or service endpoints are missing
For test modules where all tests are integration tests, use `pytestmark`:
```python
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("01_single_agent"),
]
```
When adding integration tests for a new provider, update the path filters and job assignments in **both** `python-merge-tests.yml` and `python-integration-tests.yml` — these workflows must be kept in sync. See the `python-testing` skill for details.
+442
View File
@@ -0,0 +1,442 @@
# Dev Setup
This document describes how to setup your environment with Python and uv,
if you're working on new features or a bug fix for Agent Framework, or simply
want to run the tests included.
For coding standards and conventions, see [CODING_STANDARD.md](CODING_STANDARD.md).
## System setup
We are using a tool called [poethepoet](https://github.com/nat-n/poethepoet) for task management and [uv](https://github.com/astral-sh/uv) for dependency management. At the [end of this document](#available-poe-tasks), you will find the available Poe tasks.
## If you're on WSL
Check that you've cloned the repository to `~/workspace` or a similar folder.
Avoid `/mnt/c/` and prefer using your WSL user's home directory.
Ensure you have the WSL extension for VSCode installed.
## Using uv
uv allows us to use AF from the local files, without worrying about paths, as
if you had AF pip package installed.
To install AF and all the required tools in your system, first, navigate to the directory containing
this DEV_SETUP using your chosen shell.
### For windows (non-WSL)
Check the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for the installation instructions. At the time of writing this is the command to install uv:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### For WSL, Linux or MacOS
Check the [uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for the installation instructions. At the time of writing this is the command to install uv:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
### Alternative for MacOS
For MacOS users, Homebrew provides an easy installation of uv with the [uv Formulae](https://formulae.brew.sh/formula/uv)
```bash
brew install uv
```
### After installing uv
You can then run the following commands manually:
```bash
# Install Python 3.10, 3.11, 3.12, and 3.13
uv python install 3.10 3.11 3.12 3.13
# Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13)
PYTHON_VERSION="3.10"
uv venv --python $PYTHON_VERSION
# Install AF and all dependencies
uv sync --all-groups
# Install all the tools and dependencies
uv run poe install
# Install prek hooks
uv run poe prek-install
```
Alternatively, you can reinstall the venv, pacakges, dependencies and prek hooks with a single command (but this requires poe in the current env), this is especially useful if you want to switch python versions:
```bash
uv run poe setup -p 3.13
```
You can then run different commands through Poe the Poet, use `uv run poe` to discover which ones.
## VSCode Setup
Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) for VSCode.
Open the `python` folder in [VSCode](https://code.visualstudio.com/docs/editor/workspaces).
> The workspace for python should be rooted in the `./python` folder.
Open any of the `.py` files in the project and run the `Python: Select Interpreter`
command from the command palette. Make sure the virtual env (default path is `.venv`) created by `uv` is selected.
## LLM setup
Make sure you have an
[OpenAI API Key](https://platform.openai.com) or
[Azure OpenAI service key](https://learn.microsoft.com/azure/cognitive-services/openai/quickstart?pivots=rest-api)
There are two methods to manage keys, secrets, and endpoints:
1. Store them in environment variables. AF Python leverages pydantic settings to load keys, secrets, and endpoints from the environment.
> When you are using VSCode and have the python extension setup, it automatically loads environment variables from a `.env` file, so you don't have to manually set them in the terminal.
> During runtime on different platforms, environment settings set as part of the deployments should be used.
2. Store them in a separate `.env` file, like `dev.env`, you can then pass that name into the constructor for most services, to the `env_file_path` parameter, see below.
> Make sure to add `*.env` to your `.gitignore` file.
### Example for file-based setup with OpenAI Chat Completions
To configure a `.env` file with just the keys needed for OpenAI Chat Completions, you can create a `openai.env` (this name is just as an example, a single `.env` with all required keys is more common) file in the root of the `python` folder with the following content:
Content of `.env` or `openai.env`:
```env
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-4o-mini"
```
You will then configure the ChatClient class with the keyword argument `env_file_path` (alternatively you can use `load_dotenv` in your code):
```python
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(env_file_path="openai.env")
```
## Tests
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.
The root `test` command now supports both project-scoped fan-out and a single aggregate sweep:
```bash
# Run package-local tests across all workspace packages
uv run poe test
# 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.
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 aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages.
## Code quality checks
To run the same checks that run during a commit and the GitHub Action `Python Code Quality`, you can use this command, from the [python](../python) folder:
```bash
uv run poe check
```
Ideally you should run these checks before committing any changes, when you install using the instructions above the prek hooks should be installed already.
## Code Coverage
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 -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!
## Catching up with the latest changes
There are many people committing to Agent Framework, so it is important to keep your local repository up to date. To do this, you can run the following commands:
```bash
git fetch upstream main
git rebase upstream/main
git push --force-with-lease
```
or:
```bash
git fetch upstream main
git merge upstream/main
git push
```
This is assuming the upstream branch refers to the main repository. If you have a different name for the upstream branch, you can replace `upstream` with the name of your upstream branch.
After running the rebase command, you may need to resolve any conflicts that arise. If you are unsure how to resolve a conflict, please refer to the [GitHub's documentation on resolving conflicts](https://docs.github.com/en/get-started/using-git/resolving-merge-conflicts-after-a-git-rebase), or for [VSCode](https://code.visualstudio.com/docs/sourcecontrol/overview#_merge-conflicts).
# Task automation
## Available Poe Tasks
This project uses [poethepoet](https://github.com/nat-n/poethepoet) for task management and [uv](https://github.com/astral-sh/uv) for dependency management.
### Setup and Installation
Once uv is installed, and you do not yet have a virtual environment setup:
```bash
uv venv
```
and then you can run the following tasks:
```bash
uv sync --all-extras --all-groups
```
After this initial setup, you can use the following tasks to manage your development environment. It is advised to use the following setup command since that also installs the prek hooks.
#### `setup`
Set up the development environment with a virtual environment, install dependencies and prek hooks:
```bash
uv run poe setup
# or with specific Python version
uv run poe setup -P 3.12
```
#### `install`
Install all dependencies (including extras and dependency groups) from the lockfile using frozen resolution:
```bash
uv run poe install
```
The root `dev` group contains shared tooling and source/type-check support. Package-specific test fixtures use
`test` groups, while dependencies needed for a locally executable optional feature may use a feature-named group
such as the lab package's `tau2` group.
For intentional dependency upgrades, run `uv lock --upgrade-package <dependency-name>` and then run `uv run poe install`.
For repo-wide development dependency refreshes, run `uv run poe upgrade-dev-dependencies` to repin exact
dependencies in development groups, 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 -P 3.12
```
#### `prek-install`
Install prek hooks:
```bash
uv run poe prek-install
```
### Project-scoped command families
These commands default to `--package "*"`, so they run across all workspace packages unless you narrow them with `-P/--package`:
#### `syntax`
Run Ruff formatting plus Ruff lint checks by default:
```bash
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
```
#### `build`
Build workspace packages and the root meta package:
```bash
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. Pyright is the **strict source-code type checker**, and also runs
in a relaxed `basic` profile over the tests + samples (as one of the `test-typing` checkers):
```bash
uv run poe pyright
uv run poe pyright -P core
uv run poe pyright -A
```
#### `test-typing`
Run the **tests + samples** type checkers. Source code is owned by strict Pyright; the tests
and samples are checked by `pyright` (relaxed), `mypy`, `pyrefly`, `ty`, and `zuban` in a
deliberately relaxed/basic profile so real public-API type errors surface without forcing
test/sample authors to fully annotate their code. All five gate CI:
```bash
uv run poe test-typing # all checkers over every package's tests
uv run poe test-typing -P core # one package
uv run poe test-typing -S # samples (pyright + pyrefly + ty; mypy/zuban skip script-style samples)
uv run poe test-typing -P core --checker mypy # narrow to one checker (repeatable)
uv run poe test-typing -P core --checker pyright # relaxed pyright over the tests
```
#### `mypy`
Convenience alias that runs MyPy over the test suite (MyPy no longer runs on source):
```bash
uv run poe mypy
uv run poe mypy -P core
```
#### `typing`
Run Pyright over source **and** the tests/samples checkers:
```bash
uv run poe typing
uv run poe typing -P core
uv run poe typing -A
```
#### `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:
```bash
uv run poe markdown-code-lint
```
#### `check-packages`
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 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
```
#### `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 validate-dependency-bounds-test
# Defaults to --package "*"; pass a package to scope test mode
uv run poe validate-dependency-bounds-test -P core
```
#### `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
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.
#### `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 add-dependency-and-validate-bounds -P core -D "<dependency-spec>"
```
#### `upgrade-dev-dependencies`
Refresh exact development dependency pins across the workspace, run `uv lock --upgrade`, reinstall from the frozen lockfile, then rerun validation, typing, and tests:
```bash
uv run poe upgrade-dev-dependencies
```
Use this for repo-wide development tooling and dependency-group refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.
### Building and Publishing
#### `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 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
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+85
View File
@@ -0,0 +1,85 @@
# Python Package Status
This file tracks the current lifecycle state of the Python packages in this workspace. Some packages at later stages might have features within them that are not ready yet, these have feature stage decorators on the relevant APIs, and for `experimental` features warnings are raised. See the [Feature-level staged APIs](#feature-level-staged-apis) section below for details on which features are in which stage and where to find them.
Status is grouped into these buckets:
- `alpha` - initial release and early development packages that are not yet ready for general use
- `beta` - prerelease packages that are not currently release candidates
- `rc` - release candidate packages, these are close to ready for release but may still have some breaking changes before the final release
- `released` - stable packages without a prerelease suffix, these are stable packages that should not have breaking changes between versions
- `deprecated` - removed or deprecated packages that should not be used for new work
## Current packages
| Package | Path | State |
| --- | --- | --- |
| `agent-framework` | `python/` | `released` |
| `agent-framework-a2a` | `python/packages/a2a` | `beta` |
| `agent-framework-ag-ui` | `python/packages/ag-ui` | `rc` |
| `agent-framework-anthropic` | `python/packages/anthropic` | `beta` |
| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` |
| `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` |
| `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` |
| `agent-framework-azurefunctions` | `python/packages/azurefunctions` | `beta` |
| `agent-framework-bedrock` | `python/packages/bedrock` | `beta` |
| `agent-framework-chatkit` | `python/packages/chatkit` | `beta` |
| `agent-framework-claude` | `python/packages/claude` | `beta` |
| `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` |
| `agent-framework-core` | `python/packages/core` | `released` |
| `agent-framework-declarative` | `python/packages/declarative` | `rc` |
| `agent-framework-devui` | `python/packages/devui` | `beta` |
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
| `agent-framework-hosting` | `python/packages/hosting` | `alpha` |
| `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
| `agent-framework-mistral` | `python/packages/mistral` | `alpha` |
| `agent-framework-monty` | `python/packages/monty` | `alpha` |
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
| `agent-framework-openai` | `python/packages/openai` | `released` |
| `agent-framework-orchestrations` | `python/packages/orchestrations` | `released` |
| `agent-framework-purview` | `python/packages/purview` | `beta` |
| `agent-framework-redis` | `python/packages/redis` | `beta` |
## Deprecated / removed packages
| Package | Previous path | State | Notes |
| --- | --- | --- | --- |
| `agent-framework-azure-ai` | `python/packages/azure-ai` | `deprecated` | The client classes within the `azure-ai` package were renamed, sometimes changed, and moved to `agent-framework-foundry`. |
## Feature-level staged APIs
The following feature IDs have explicit feature-stage decorators on public APIs in the packages
listed below.
### Experimental features
#### `DECLARATIVE_AGENTS`
- `agent-framework-declarative`: declarative agent loading APIs from
`agent_framework_declarative`, including `AgentFactory`,
`DeclarativeLoaderError`, `ProviderLookupError`, and `ProviderTypeMapping`
from `agent_framework_declarative/_loader.py`
#### `EVALS`
- `agent-framework-core`: exported evaluation APIs from `agent_framework`, including
`LocalEvaluator`, `evaluate_agent`, `evaluate_workflow`, and the related evaluation types and
helper checks defined in `agent_framework/_evaluation.py`
- `agent-framework-foundry`: `FoundryEvals`, `evaluate_traces`, and `evaluate_foundry_target`
#### `SKILLS`
- `agent-framework-core`: exported skills APIs from `agent_framework`, including `Skill`,
`SkillResource`, `SkillScript`, `SkillScriptRunner`, and `SkillsProvider` from
`agent_framework/_skills.py`
### Release-candidate features
There are currently no feature-level `rc` APIs.
+261
View File
@@ -0,0 +1,261 @@
# Get Started with Microsoft Agent Framework for Python Developers
## Quick Install
We recommend two common installation paths depending on your use case.
### 1. Development mode
If you are exploring or developing locally, install the entire framework with all sub-packages:
```bash
pip install agent-framework
```
This installs the core and every integration package, making sure that all features are available without additional steps. This is the simplest way to get started.
### 2. Selective install
If you only need specific integrations, you can install at a more granular level. This keeps dependencies lighter and focuses on what you actually plan to use. Some examples:
```bash
# Core only
# includes Azure OpenAI and OpenAI support by default
# also includes workflows and orchestrations
pip install agent-framework-core
# Core + Azure AI Foundry integration
pip install agent-framework-foundry
# Core + Microsoft Copilot Studio integration (preview package)
pip install agent-framework-copilotstudio --pre
# Core + both Microsoft Copilot Studio and Azure AI Foundry integration
pip install --pre agent-framework-copilotstudio agent-framework-foundry
```
This selective approach is useful when you know which integrations you need, and it is the recommended way to set up lightweight environments. Released packages such as `agent-framework`, `agent-framework-core`, and `agent-framework-foundry` no longer require `--pre`, while preview connectors such as `agent-framework-copilotstudio` still do.
Supported Platforms:
- Python: 3.10+
- OS: Windows, macOS, Linux
## 1. Setup API Keys
Set as environment variables, or create a .env file at your project root:
```bash
OPENAI_API_KEY=sk-...
OPENAI_MODEL=...
...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_ENDPOINT=...
AZURE_OPENAI_MODEL=...
...
FOUNDRY_PROJECT_ENDPOINT=...
FOUNDRY_MODEL=...
```
For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), configuration
resolves in this order:
1. Explicit Azure inputs such as `credential` or `azure_endpoint`
2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters
3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY`
This means mixed shells default to OpenAI when `OPENAI_API_KEY` is present. To force Azure routing,
pass an explicit Azure input such as `credential=AzureCliCredential()`.
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
```python
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(
api_key='',
azure_endpoint='',
model='',
api_version='',
)
```
See the following [setup guide](samples/01-get-started) for more information.
## 2. Create a Simple Agent
Create agents and invoke them directly:
```python
import asyncio
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
async def main():
agent = Agent(
client=OpenAIChatClient(),
instructions="""
1) A robot may not injure a human being...
2) A robot must obey orders given it by human beings...
3) A robot must protect its own existence...
Give me the TLDR in exactly 5 words.
"""
)
result = await agent.run("Summarize the Three Laws of Robotics")
print(result)
asyncio.run(main())
# Output: Protect humans, obey, self-preserve, prioritized.
```
## 3. Directly Use Chat Clients (No Agent Required)
You can use the chat client classes directly for advanced workflows:
```python
import asyncio
from agent_framework import Message
from agent_framework.openai import OpenAIChatClient
async def main():
client = OpenAIChatClient()
messages = [
Message("system", ["You are a helpful assistant."]),
Message("user", ["Write a haiku about Agent Framework."])
]
response = await client.get_response(messages)
print(response.messages[0].text)
"""
Output:
Agents work in sync,
Framework threads through each task—
Code sparks collaboration.
"""
asyncio.run(main())
```
## 4. Build an Agent with Tools and Functions
Enhance your agent with custom tools and function calling:
```python
import asyncio
from typing import Annotated
from random import randint
from pydantic import Field
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
def get_menu_specials() -> str:
"""Get today's menu specials."""
return """
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
"""
async def main():
agent = Agent(
client=OpenAIChatClient(),
instructions="You are a helpful assistant that can provide weather and restaurant information.",
tools=[get_weather, get_menu_specials]
)
response = await agent.run("What's the weather in Amsterdam and what are today's specials?")
print(response)
"""
Output:
The weather in Amsterdam is sunny with a high of 22°C. Today's specials include
Clam Chowder soup, Cobb Salad, and Chai Tea as the special drink.
"""
if __name__ == "__main__":
asyncio.run(main())
```
You can explore additional agent samples [here](samples/02-agents).
## 5. Multi-Agent Orchestration
Coordinate multiple agents to collaborate on complex tasks using orchestration patterns:
```python
import asyncio
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
async def main():
# Create specialized agents
writer = Agent(
client=OpenAIChatClient(),
name="Writer",
instructions="You are a creative content writer. Generate and refine slogans based on feedback."
)
reviewer = Agent(
client=OpenAIChatClient(),
name="Reviewer",
instructions="You are a critical reviewer. Provide detailed feedback on proposed slogans."
)
# Sequential workflow: Writer creates, Reviewer provides feedback
task = "Create a slogan for a new electric SUV that is affordable and fun to drive."
# Step 1: Writer creates initial slogan
initial_result = await writer.run(task)
print(f"Writer: {initial_result}")
# Step 2: Reviewer provides feedback
feedback_request = f"Please review this slogan: {initial_result}"
feedback = await reviewer.run(feedback_request)
print(f"Reviewer: {feedback}")
# Step 3: Writer refines based on feedback
refinement_request = f"Please refine this slogan based on the feedback: {initial_result}\nFeedback: {feedback}"
final_result = await writer.run(refinement_request)
print(f"Final Slogan: {final_result}")
# Example Output:
# Writer: "Charge Forward: Affordable Adventure Awaits!"
# Reviewer: "Good energy, but 'Charge Forward' is overused in EV marketing..."
# Final Slogan: "Power Up Your Adventure: Premium Feel, Smart Price!"
if __name__ == "__main__":
asyncio.run(main())
```
For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/03-workflows/orchestrations).
## More Examples & Samples
- [Getting Started with Agents](samples/02-agents): Basic agent creation and tool usage
- [Chat Client Examples](samples/02-agents/chat_client): Direct chat client usage patterns
- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Microsoft Foundry integration
- [Workflow Samples](samples/03-workflows): Advanced multi-agent patterns
## Agent Framework Documentation
- [Agent Framework Repository](https://github.com/microsoft/agent-framework)
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
- [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design)
- Learn docs are coming soon.
+31
View File
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
from importlib import metadata as _metadata
from pathlib import Path as _Path
from typing import Any, cast
try:
import tomllib as _toml # type: ignore # Python 3.11+
except ModuleNotFoundError: # Python 3.10
import tomli as _toml # type: ignore
def _load_pyproject() -> dict[str, Any]:
pyproject = (_Path(__file__).resolve().parents[1] / "pyproject.toml").read_text("utf-8")
return cast(dict[str, Any], _toml.loads(pyproject)) # type: ignore
def _version() -> str:
try:
return _metadata.version("agent-framework")
except _metadata.PackageNotFoundError as ex:
data = _load_pyproject()
project = cast(dict[str, Any], data.get("project", {}))
version = project.get("version")
if isinstance(version, str):
return version
raise RuntimeError("pyproject.toml missing project.version") from ex
__version__ = _version()
__all__ = ["__version__"]
+10
View File
@@ -0,0 +1,10 @@
uv python install 3.10 3.11 3.12 3.13
# Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13)
PYTHON_VERSION="3.13"
uv venv --python $PYTHON_VERSION
# Install AF and all dependencies
uv sync --dev
# Install all the tools and dependencies
uv run poe install
# Install prek hooks
uv run poe prek-install
+58
View File
@@ -0,0 +1,58 @@
# A2A Package (agent-framework-a2a)
Agent-to-Agent (A2A) protocol support for inter-agent communication.
## Main Classes
- **`A2AAgent`** - Client to connect to remote A2A-compliant agents.
- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol.
- **`A2AServiceSessionId`** - Typed durable A2A continuation state shape stored in `AgentSession.service_session_id`.
- **`A2AAgentSession`** - Deprecated compatibility session wrapper; prefer `AgentSession` + `A2AServiceSessionId`.
## Usage
### A2AAgent (Client)
```python
from agent_framework.a2a import A2AAgent
# Connect to a remote A2A agent
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
response = await a2a_agent.run("Hello!")
```
### A2AExecutor (Server/Bridge)
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from starlette.applications import Starlette
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler (agent_card is required)
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
agent_card=my_agent_card,
)
# Build a Starlette app with A2A routes
app = Starlette(
routes=[
*create_agent_card_routes(my_agent_card),
*create_jsonrpc_routes(request_handler, "/"),
]
)
```
## Import Path
```python
from agent_framework.a2a import A2AAgent, A2AExecutor, A2AServiceSessionId
# or directly:
from agent_framework_a2a import A2AAgent, A2AExecutor, A2AServiceSessionId
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+64
View File
@@ -0,0 +1,64 @@
# Get Started with Microsoft Agent Framework A2A
Please install this package via pip:
```bash
pip install agent-framework-a2a --pre
```
## A2A Agent Integration
The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services.
### A2AAgent (Client)
The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents.
```python
from agent_framework.a2a import A2AAgent
# Connect to a remote A2A agent
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
response = await a2a_agent.run("Hello!")
```
### A2AExecutor (Hosting)
The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler and server application
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
)
app = A2AStarletteApplication(
agent_card=my_agent_card,
http_handler=request_handler,
).build()
```
### Basic Usage Example
See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate:
- Connecting to remote A2A agents
- Hosting local agents via A2A protocol
- Sending messages and receiving responses
- Handling different content types (text, files, data)
- Streaming responses and real-time interaction
## Security considerations
The hosting example above focuses on protocol wiring and does not add authentication or authorization by itself. Production A2A hosts should protect their HTTP or JSON-RPC entry points with the deployment's normal auth layer and verify that each caller is allowed to access the requested agent, task, or session.
Task, thread, context, and session identifiers used by an A2A host are routing handles, not bearer credentials. Do not rely on client-supplied identifiers alone to select or mutate persisted state; bind them to authenticated user, tenant, or workspace context first.
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._a2a_executor import A2AExecutor
from ._agent import A2AAgent, A2AAgentSession, A2AContinuationToken, A2AServiceSessionId
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"A2AAgent",
"A2AAgentSession",
"A2AContinuationToken",
"A2AExecutor",
"A2AServiceSessionId",
"__version__",
]
@@ -0,0 +1,300 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import logging
import uuid
from asyncio import CancelledError
from collections.abc import Mapping
from functools import partial
from typing import Any
from a2a.helpers import new_task_from_user_message
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import Part, TaskState
from agent_framework import (
AgentResponseUpdate,
AgentSession,
Message,
SupportsAgentRun,
)
from typing_extensions import override
from ._utils import get_uri_data
logger = logging.getLogger("agent_framework.a2a")
class A2AExecutor(AgentExecutor):
"""Execute AI agents using the A2A (Agent-to-Agent) protocol.
The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol,
enabling structured agent execution with event-driven communication. It handles execution
contexts, delegates history management to the agent's session, and converts agent
responses into A2A protocol events.
The executor supports executing an Agent or WorkflowAgent. It provides comprehensive
error handling with task status updates and supports various content types including text,
binary data, and URI-based content.
Example:
.. code-block:: python
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_jsonrpc_routes, create_agent_card_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIResponsesClient
from starlette.applications import Starlette
public_agent_card = AgentCard(
name="Food Agent",
description="A simple agent that provides food-related information.",
version="1.0.0",
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC"),
],
skills=[],
)
# Create an agent
agent = OpenAIResponsesClient().as_agent(
name="Food Agent",
instructions="A simple agent that provides food-related information.",
)
# Set up the A2A server with the A2AExecutor enabled for streaming
# and passing custom keyword arguments to the agent's run method.
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
task_store=InMemoryTaskStore(),
agent_card=public_agent_card,
)
app = Starlette(
routes=[
*create_agent_card_routes(public_agent_card),
*create_jsonrpc_routes(request_handler, "/"),
],
)
Args:
agent: The AI agent to execute.
stream: Whether to stream the agent response. Defaults to False.
run_kwargs: Additional keyword arguments to pass to the agent's run method.
"""
def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None):
"""Initialize the A2AExecutor with the specified agent.
Args:
agent: The AI agent or workflow to execute.
stream: Whether to stream the agent response. Defaults to False.
run_kwargs: Additional keyword arguments to pass to the agent's run method.
Cannot contain 'session' or 'stream' as these are managed by the executor.
Raises:
ValueError: If run_kwargs contains 'session' or 'stream'.
"""
super().__init__()
self._agent: SupportsAgentRun = agent
self._stream: bool = stream
if run_kwargs:
if "session" in run_kwargs:
raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.")
if "stream" in run_kwargs:
raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.")
self._run_kwargs: Mapping[str, Any] = run_kwargs or {}
@override
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Cancel agent execution for the given request context.
Uses a TaskUpdater to send a cancellation event through the provided event queue.
Args:
context: The request context identifying the task to cancel.
event_queue: The event queue to publish the cancellation event to.
Raises:
ValueError: If context_id is not provided in the RequestContext.
"""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
updater = TaskUpdater(
event_queue=event_queue,
task_id=context.task_id or "",
context_id=context.context_id,
)
await updater.cancel()
@override
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Execute the agent with the given context and event queue.
Orchestrates the agent execution process: sets up the agent session,
executes the agent, processes response messages, and handles errors with appropriate task status updates.
"""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
if context.message is None:
raise ValueError("Message must be provided in the RequestContext")
query = context.get_user_input()
task = context.current_task
if not task:
task = new_task_from_user_message(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, context.context_id)
await updater.submit()
try:
await updater.start_work()
session = self._agent.create_session(session_id=task.context_id)
if self._stream:
await self._run_stream(query, session, updater)
else:
await self._run(query, session, updater)
# Mark as complete
await updater.complete()
except CancelledError:
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
except Exception as e:
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
await updater.update_status(
state=TaskState.TASK_STATE_FAILED,
message=updater.new_agent_message([Part(text=str(e))]),
)
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
"""Run the agent in streaming mode and publish updates to the task updater."""
response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs)
streamed_artifact_ids: set[str] = set()
# Generate a stable artifact ID for the entire stream so all chunks share the same ID.
# This ensures clients can coalesce streaming tokens into a single artifact/message
# per the A2A spec (TaskArtifactUpdateEvent with append=True on same artifactId).
default_artifact_id = str(uuid.uuid4())
await (
response_stream.with_transform_hook(
partial(
self.handle_events,
updater=updater,
streamed_artifact_ids=streamed_artifact_ids,
default_artifact_id=default_artifact_id,
)
)
).get_final_response()
async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
"""Run the agent in non-streaming mode and publish messages to the task updater."""
response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs)
response_messages = response.messages
if not isinstance(response_messages, list):
response_messages = [response_messages]
for message in response_messages:
await self.handle_events(message, updater)
async def handle_events(
self,
item: Message | AgentResponseUpdate,
updater: TaskUpdater,
streamed_artifact_ids: set[str] | None = None,
default_artifact_id: str | None = None,
) -> None:
"""Convert agent response items (Messages or Updates) to A2A protocol events.
Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format.
Handles text, data, and URI content. USER role messages are skipped.
Users can override this method in a subclass to implement custom transformations
from their agent's output format to A2A protocol events.
Args:
item: The agent response item (Message or AgentResponseUpdate) to process.
updater: The task updater to publish events to.
streamed_artifact_ids: A set of artifact IDs that have already been streamed.
Used to track which artifacts need append=True on subsequent chunks.
default_artifact_id: A stable artifact ID to use when the item does not provide one.
This ensures all streaming chunks for a single response share the same artifact ID,
allowing clients to coalesce them into a single message.
Example:
.. code-block:: python
class CustomA2AExecutor(A2AExecutor):
async def handle_events(
self,
item: Message | AgentResponseUpdate,
updater: TaskUpdater,
streamed_artifact_ids: set[str] | None = None,
default_artifact_id: str | None = None,
) -> None:
# Custom logic to transform item contents
if item.role == "assistant" and item.contents:
parts = [Part(text=f"Custom: {item.contents[0].text}")]
await updater.update_status(
state=TaskState.TASK_STATE_WORKING,
message=updater.new_agent_message(parts=parts),
)
else:
await super().handle_events(item, updater)
"""
role = getattr(item, "role", None)
if role == "user":
# This is a user message, we can ignore it in the context of task updates
return
parts: list[Part] = []
metadata = getattr(item, "additional_properties", None)
# AgentResponseUpdate uses 'contents', Message uses 'contents'
contents = getattr(item, "contents", [])
for content in contents:
if content.type == "text" and content.text:
parts.append(Part(text=content.text))
elif content.type == "data" and content.uri:
base64_str = get_uri_data(content.uri)
parts.append(Part(raw=base64.b64decode(base64_str), media_type=content.media_type or ""))
elif content.type == "uri" and content.uri:
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
else:
# Silently skip unsupported content types
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
if parts:
if isinstance(item, AgentResponseUpdate):
# Resolve artifact ID: use item's message_id if available, otherwise fall back
# to the stable default_artifact_id so all streaming chunks share the same ID.
artifact_id = item.message_id or default_artifact_id
# For streaming updates, we send TaskArtifactUpdateEvent via add_artifact
await updater.add_artifact(
parts=parts,
artifact_id=artifact_id,
metadata=metadata,
append=(
True if streamed_artifact_ids is not None and artifact_id in streamed_artifact_ids else None
),
)
if artifact_id and streamed_artifact_ids is not None:
streamed_artifact_ids.add(artifact_id)
else:
# For final messages, we send TaskStatusUpdateEvent with 'working' state
await updater.update_status(
state=TaskState.TASK_STATE_WORKING,
message=updater.new_agent_message(parts=parts, metadata=metadata),
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import re
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;,]+(?:;[^;,=]+=[^;,]+)*);base64,(?P<base64_data>[A-Za-z0-9+/=]+)\Z")
def get_uri_data(uri: str) -> str:
"""Extracts the base64-encoded data from a data URI.
Args:
uri: The data URI to parse.
Returns:
The base64-encoded data part of the URI.
Raises:
ValueError: If the URI format is invalid.
"""
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid data URI format: {uri}")
return match.group("base64_data")
+98
View File
@@ -0,0 +1,98 @@
[project]
name = "agent-framework-a2a"
description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"a2a-sdk>=1.0.0,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_a2a"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_a2a"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[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"]
build-backend = "flit_core.buildapi"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,907 @@
# Copyright (c) Microsoft. All rights reserved.
from asyncio import CancelledError
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from a2a.types import Part, Task, TaskState
from agent_framework import (
AgentResponseUpdate,
Content,
Message,
SupportsAgentRun,
)
from agent_framework._types import AgentResponse
from agent_framework.a2a import A2AExecutor
from pytest import fixture, raises
@fixture
def mock_agent() -> MagicMock:
"""Fixture that provides a mock SupportsAgentRun."""
agent = MagicMock(spec=SupportsAgentRun)
agent.run = AsyncMock()
return agent
@fixture
def mock_request_context() -> MagicMock:
"""Fixture that provides a mock RequestContext."""
request_context = MagicMock()
request_context.context_id = str(uuid4())
request_context.get_user_input = MagicMock(return_value="Test query")
request_context.current_task = None
request_context.message = None
return request_context
@fixture
def mock_event_queue() -> MagicMock:
"""Fixture that provides a mock EventQueue."""
queue = AsyncMock()
queue.enqueue_event = AsyncMock()
return queue
@fixture
def mock_task() -> Task:
"""Fixture that provides a mock Task."""
task = MagicMock(spec=Task)
task.id = str(uuid4())
task.context_id = str(uuid4())
task.state = TaskState.TASK_STATE_COMPLETED
return task
@fixture
def mock_task_updater() -> MagicMock:
"""Fixture that provides a mock TaskUpdater."""
updater = MagicMock()
updater.submit = AsyncMock()
updater.start_work = AsyncMock()
updater.complete = AsyncMock()
updater.update_status = AsyncMock()
updater.new_agent_message = MagicMock()
return updater
@fixture
def executor(mock_agent: MagicMock) -> A2AExecutor:
"""Fixture that provides an A2AExecutor."""
return A2AExecutor(agent=mock_agent)
class TestA2AExecutorInitialization:
"""Tests for A2AExecutor initialization."""
def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with only agent
Assert: Executor is created with default values
"""
# Act
executor = A2AExecutor(agent=mock_agent)
# Assert
assert executor._agent is mock_agent
assert executor._stream is False
assert executor._run_kwargs == {}
def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with stream and run_kwargs
Assert: Executor is created with specified values
"""
# Arrange
run_kwargs = {"temperature": 0.5}
# Act
executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs)
# Assert
assert executor._agent is mock_agent
assert executor._stream is True
assert executor._run_kwargs == run_kwargs
def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with reserved keys in run_kwargs
Assert: ValueError is raised
"""
# Act & Assert
with raises(ValueError, match="run_kwargs cannot contain 'session'"):
A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"})
with raises(ValueError, match="run_kwargs cannot contain 'stream'"):
A2AExecutor(agent=mock_agent, run_kwargs={"stream": True})
class TestA2AExecutorCancel:
"""Tests for the cancel method."""
async def test_cancel_method_completes(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with dependencies
Act: Call cancel method
Assert: Method completes without raising error
"""
# Arrange
mock_request_context.task_id = "task-123"
# Act & Assert (should not raise)
await executor.cancel(mock_request_context, mock_event_queue) # type: ignore
async def test_cancel_handles_different_contexts(
self,
executor: A2AExecutor,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with multiple request contexts
Act: Call cancel with different contexts
Assert: Each cancel completes successfully
"""
# Arrange
context1 = MagicMock()
context1.context_id = "ctx-1"
context1.task_id = "task-1"
context2 = MagicMock()
context2.context_id = "ctx-2"
context2.task_id = "task-2"
# Act & Assert
await executor.cancel(context1, mock_event_queue) # type: ignore
await executor.cancel(context2, mock_event_queue) # type: ignore
async def test_cancel_raises_error_when_context_id_missing(
self,
executor: A2AExecutor,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without context_id
Act: Call cancel method
Assert: ValueError is raised
"""
# Arrange
mock_context = MagicMock()
mock_context.context_id = None
# Act & Assert
with raises(ValueError) as excinfo:
await executor.cancel(mock_context, mock_event_queue) # type: ignore
# Assert
assert "Context ID" in str(excinfo.value)
class TestA2AExecutorExecute:
"""Tests for the execute method."""
async def test_execute_with_existing_task_succeeds(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with mocked dependencies and existing task
Act: Call execute method
Assert: Execution completes successfully
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor._agent).create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.submit.assert_called_once()
mock_updater.start_work.assert_called_once()
mock_updater.complete.assert_called_once()
cast(Any, executor._agent.create_session).assert_called_once()
cast(Any, executor._agent.run).assert_called_once()
async def test_execute_creates_task_when_not_exists(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with request context without task
Act: Call execute method
Assert: New task is created and enqueued
"""
# Arrange
mock_message = MagicMock()
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = None
mock_request_context.message = mock_message
mock_request_context.context_id = "ctx-123"
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor._agent).create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.new_task_from_user_message") as mock_new_task:
mock_task = MagicMock(spec=Task)
mock_task.id = "task-new"
mock_task.context_id = "ctx-123"
mock_new_task.return_value = mock_task
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_new_task.assert_called_once()
mock_event_queue.enqueue_event.assert_called_once()
async def test_execute_raises_error_when_context_id_missing(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without context_id
Act: Call execute method
Assert: ValueError is raised
"""
# Arrange
mock_request_context.context_id = None
mock_request_context.message = MagicMock()
# Act & Assert
with raises(ValueError) as excinfo:
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert "Context ID" in str(excinfo.value)
async def test_execute_raises_error_when_message_missing(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without message
Act: Call execute method
Assert: ValueError is raised
"""
# Arrange
mock_request_context.context_id = "ctx-123"
mock_request_context.message = None
# Act & Assert
with raises(ValueError) as excinfo:
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert "Message" in str(excinfo.value)
async def test_execute_handles_cancelled_error(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that raises CancelledError
Act: Call execute method
Assert: Error is caught and task is marked as canceled
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
cast(Any, executor._agent).run = AsyncMock(side_effect=CancelledError())
cast(Any, executor._agent).create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue) # type: ignore
# Assert
mock_updater.update_status.assert_called()
call_args_list = mock_updater.update_status.call_args_list
assert any(call[1].get("state") == TaskState.TASK_STATE_CANCELED for call in call_args_list)
async def test_execute_handles_generic_exception(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that raises generic exception
Act: Call execute method
Assert: Error is caught and task is marked as failed
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
error_message = "Test error"
cast(Any, executor._agent).run = AsyncMock(side_effect=ValueError(error_message))
cast(Any, executor._agent).create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="error_message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.new_agent_message.assert_called_once()
args, _ = mock_updater.new_agent_message.call_args
parts = args[0]
assert len(parts) == 1
assert isinstance(parts[0], Part)
assert parts[0].text == error_message
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.TASK_STATE_FAILED and call[1].get("message") == "error_message_obj"
for call in call_args_list
)
async def test_execute_processes_multiple_response_messages(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that returns multiple response messages
Act: Call execute method
Assert: All messages are processed through handle_events
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")])
response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message1, response_message2]
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor._agent).create_session = MagicMock()
# Mock handle_events
cast(Any, executor).handle_events = AsyncMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert cast(Any, executor.handle_events).call_count == 2
async def test_execute_passes_query_to_run(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with request
Act: Call execute method
Assert: Query text is passed to run method with default stream and kwargs
"""
# Arrange
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor._agent).create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
cast(Any, executor._agent.run).assert_called_once_with(
query_text, session=executor._agent.create_session(), stream=False
)
async def test_execute_with_stream_enabled(
self,
mock_agent: MagicMock,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with stream=True
Act: Call execute method
Assert: _run_stream is called and passes stream=True to run
"""
# Arrange
executor = A2AExecutor(agent=mock_agent, stream=True)
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
mock_response_stream = MagicMock()
mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream)
mock_response_stream.get_final_response = AsyncMock()
mock_agent.run = MagicMock(return_value=mock_response_stream)
mock_agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True)
mock_response_stream.with_transform_hook.assert_called_once()
mock_response_stream.get_final_response.assert_called_once()
async def test_execute_with_run_kwargs(
self,
mock_agent: MagicMock,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with run_kwargs
Act: Call execute method
Assert: run_kwargs are passed to run method
"""
# Arrange
run_kwargs = {"temperature": 0.5, "max_tokens": 100}
executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs)
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
mock_agent.run = AsyncMock(return_value=response)
mock_agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_agent.run.assert_called_once_with(
query_text, session=mock_agent.create_session(), stream=False, **run_kwargs
)
class TestA2AExecutorHandleEvents:
"""Tests for A2AExecutor.handle_events method."""
async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test the private _run method with a single message (not a list)."""
# Arrange
query = "test query"
session = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = response_message # Not a list
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor).handle_events = AsyncMock()
# Act
await executor._run(query, session, mock_updater)
# Assert
cast(Any, executor.handle_events).assert_called_once_with(response_message, mock_updater)
@fixture
def mock_updater(self) -> MagicMock:
"""Create a mock execution context."""
updater = MagicMock()
updater.update_status = AsyncMock()
updater.new_agent_message = MagicMock(return_value="mock_message")
return updater
async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that messages from USER role are ignored."""
# Arrange
message = Message(
contents=[Content.from_text(text="User input")],
role="user",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_not_called()
async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that messages with no contents are ignored."""
# Arrange
message = Message(
contents=[],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_not_called()
async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with text content."""
# Arrange
text = "Hello, this is a test message"
message = Message(
contents=[Content.from_text(text=text)],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
assert mock_updater.new_agent_message.called
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with multiple text contents."""
# Arrange
message = Message(
contents=[
Content.from_text(text="First message"),
Content.from_text(text="Second message"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
assert mock_updater.new_agent_message.called
async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with data content."""
# Arrange
data = b"test file data"
message = Message(
contents=[Content.from_data(data=data, media_type="application/octet-stream")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with URI content."""
# Arrange
uri = "https://example.com/file.pdf"
message = Message(
contents=[Content.from_uri(uri=uri, media_type="application/pdf")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with mixed content types."""
# Arrange
data = b"file data"
message = Message(
contents=[
Content.from_text(text="Processing file..."),
Content.from_data(data=data, media_type="application/octet-stream"),
Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with additional properties metadata."""
# Arrange
additional_props = {"custom_field": "custom_value", "priority": "high"}
message = Message(
contents=[Content.from_text(text="Test message")],
role="assistant",
additional_properties=additional_props,
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
mock_updater.new_agent_message.assert_called_once()
call_args = mock_updater.new_agent_message.call_args
assert call_args.kwargs["metadata"] == additional_props
async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages without additional properties."""
# Arrange
message = Message(
contents=[Content.from_text(text="Test message")],
role="assistant",
additional_properties=None,
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
mock_updater.new_agent_message.assert_called_once()
call_args = mock_updater.new_agent_message.call_args
assert call_args.kwargs["metadata"] == {}
async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that parts list is correctly passed to new_agent_message."""
# Arrange
message = Message(
contents=[
Content.from_text(text="Message 1"),
Content.from_text(text="Message 2"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.new_agent_message.assert_called_once()
call_kwargs = mock_updater.new_agent_message.call_args.kwargs
assert "parts" in call_kwargs
parts_list = call_kwargs["parts"]
assert len(parts_list) == 2
async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that task state is always set to working."""
# Arrange
message = Message(
contents=[Content.from_text(text="Any message")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
call_kwargs = mock_updater.update_status.call_args.kwargs
assert call_kwargs["state"] == TaskState.TASK_STATE_WORKING
async def test_handle_agent_response_update_no_streamed_set(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) without a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Streaming chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
# Act
await executor.handle_events(update, mock_updater)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["artifact_id"] == "msg-1"
assert call_kwargs["append"] is None
async def test_handle_agent_response_update_first_time(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) for the first time with a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Streaming chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
streamed_artifact_ids: set[str] = set()
# Act
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["append"] is None
assert "msg-1" in streamed_artifact_ids
async def test_handle_agent_response_update_subsequent_time(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Next chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
streamed_artifact_ids = {"msg-1"}
# Act
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["append"] is True
async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with unsupported content types."""
# Arrange
message = Message(
contents=[Content(type=cast(Any, "unknown"), text="Some text")], # type: ignore[arg-type]
role="assistant",
)
# Act
with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger:
await executor.handle_events(message, mock_updater)
# Assert
mock_logger.warning.assert_called_once()
mock_updater.update_status.assert_not_called()
class TestA2AExecutorIntegration:
"""Integration tests for A2AExecutor."""
async def test_full_execution_flow_with_responses(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with all mocked dependencies
Act: Execute full flow from request to completion
Assert: All components interact correctly
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello agent")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response = MagicMock(spec=AgentResponse)
response_message = MagicMock(spec=Message)
response.messages = [response_message]
response_message.contents = [Content.from_text(text="Hello user")]
response_message.role = "assistant"
response_message.additional_properties = None
cast(Any, executor._agent).run = AsyncMock(return_value=response)
cast(Any, executor._agent).create_session = MagicMock()
cast(Any, executor).handle_events = AsyncMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.submit.assert_called_once()
mock_updater.start_work.assert_called_once()
cast(Any, executor.handle_events).assert_called_once()
mock_updater.complete.assert_called_once()
+51
View File
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
import pytest
from agent_framework_a2a._utils import get_uri_data
def test_get_uri_data_valid() -> None:
"""Test get_uri_data with valid data URIs."""
# Simple text/plain
uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
# Image png
uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
# Application octet-stream
uri = "data:application/octet-stream;base64,AQIDBA=="
assert get_uri_data(uri) == "AQIDBA=="
# Media type with parameters
uri = "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ=="
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
# Media type with multiple parameters
uri = "data:text/plain;charset=utf-8;name=hello.txt;base64,SGVsbG8sIFdvcmxkIQ=="
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
def test_get_uri_data_invalid_format() -> None:
"""Test get_uri_data with invalid URI formats."""
invalid_uris = [
"not-a-uri",
"http://example.com",
"data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker
"data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type
"data:text/plain;foo;base64,SGVsbG8sIFdvcmxkIQ==", # Parameter without value
"data:text/plain;base64;base64,SGVsbG8sIFdvcmxkIQ==", # base64 used as a parameter name
"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra",
"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==\n",
]
for uri in invalid_uris:
with pytest.raises(ValueError, match="Invalid data URI format"):
get_uri_data(uri)
def test_get_uri_data_empty() -> None:
"""Test get_uri_data with empty string."""
with pytest.raises(ValueError, match="Invalid data URI format"):
get_uri_data("")
+51
View File
@@ -0,0 +1,51 @@
# AG-UI Package (agent-framework-ag-ui)
AG-UI protocol integration for building agent UIs with the AG-UI standard.
## Main Classes
- **`AgentFrameworkAgent`** - Wraps agents for AG-UI compatibility
- **`AgentFrameworkWorkflow`** - Wraps native `Workflow` objects, or accepts `workflow_factory(thread_id)` for thread-scoped workflow instances without subclassing
- **`AGUIChatClient`** - Chat client that speaks AG-UI protocol
- **`AGUIHttpService`** - HTTP service for AG-UI endpoints
- **`AGUIEventConverter`** - Converts between Agent Framework and AG-UI events
- **`add_agent_framework_fastapi_endpoint()`** - Add AG-UI endpoint to FastAPI app (`SupportsAgentRun` or `Workflow`)
- **`InMemoryAGUIThreadSnapshotStore`** - Memory-only latest AG-UI Thread Snapshot store for local development, demos, and tests
## Types
- **`AGUIRequest`** / **`AGUIChatOptions`** - Request types
- **`AGUIThreadSnapshot`** / **`AGUIThreadSnapshotStore`** - Replayable thread snapshot model and scoped async store protocol
- **`availableInterrupts` / `resume`** - Optional canonical AG-UI `Interrupt` and `ResumeEntry` protocol data
- **`AgentState`** / **`RunMetadata`** - State management types
- **`PredictStateConfig`** - Configuration for state prediction
## Protocol Notes
- Outbound custom events are emitted as AG-UI `CUSTOM`.
- Usage metadata from `Content(type="usage")` is surfaced as `CUSTOM` events with `name="usage"`.
- Inbound custom event aliases are accepted: `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`.
- Multimodal user inputs support both legacy (`text`, `binary`) and draft-style (`image`, `audio`, `video`, `document`) shapes.
- Interrupted runs complete with `RUN_FINISHED.outcome.type == "interrupt"` and canonical `outcome.interrupts`; do not document or add new flows that depend on the legacy top-level `RUN_FINISHED.interrupt` field.
- `Interrupt` and `ResumeEntry` come from the `ag-ui-protocol` package (`ag_ui.core`), not from an Agent Framework-specific interrupt model.
- SSE keepalive is endpoint-owned transport behavior configured through
`add_agent_framework_fastapi_endpoint(keepalive_seconds=...)`. It emits SSE comments only; do not add `PING`,
`HEARTBEAT`, or `KEEPALIVE` AG-UI events, and do not add runner-level keepalive settings.
## Usage
```python
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent)
```
## Import Path
```python
from agent_framework.ag_ui import AGUIChatClient, add_agent_framework_fastapi_endpoint
# or directly:
from agent_framework_ag_ui import AGUIChatClient
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+378
View File
@@ -0,0 +1,378 @@
# Agent Framework AG-UI Integration
AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol.
## Installation
```bash
pip install agent-framework-ag-ui
```
## Quick Start
### Server (Host an AI Agent)
```python
from fastapi import FastAPI
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = Agent(
name="my_agent",
instructions="You are a helpful assistant.",
client=OpenAIChatCompletionClient(
azure_endpoint="https://your-resource.openai.azure.com/",
model="gpt-4o-mini",
api_key="your-api-key",
),
)
# Create FastAPI app and add AG-UI endpoint
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent, "/")
# Run with: uvicorn main:app --reload
```
### Server (Host a Workflow)
```python
from fastapi import FastAPI
from agent_framework import WorkflowBuilder, WorkflowContext, executor
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
@executor(id="start")
async def start(message: str, ctx: WorkflowContext) -> None:
await ctx.yield_output(f"Workflow received: {message}")
workflow = WorkflowBuilder(start_executor=start).build()
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, workflow, "/")
```
### Server (Thread-Scoped WorkflowBuilder)
Use `workflow_factory` when your workflow keeps runtime state (for example pending `request_info` interrupts) and must be isolated per AG-UI thread:
```python
from fastapi import FastAPI
from agent_framework import Workflow, WorkflowBuilder
from agent_framework.ag_ui import AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
def build_workflow_for_thread(thread_id: str) -> Workflow:
# Build a fresh workflow instance for each thread id.
return WorkflowBuilder(start_executor=...).build()
app = FastAPI()
thread_scoped_workflow = AgentFrameworkWorkflow(
workflow_factory=build_workflow_for_thread,
name="my_workflow",
)
add_agent_framework_fastapi_endpoint(app, thread_scoped_workflow, "/")
```
### Client (Connect to an AG-UI Server)
```python
import asyncio
from agent_framework.ag_ui import AGUIChatClient
async def main():
async with AGUIChatClient(endpoint="http://localhost:8000/") as client:
# Stream responses
async for update in client.get_response("Hello!", stream=True):
for content in update.contents:
if content.type == "text" and content.text:
print(content.text, end="", flush=True)
print()
asyncio.run(main())
```
The `AGUIChatClient` supports:
- Streaming and non-streaming responses
- Hybrid tool execution (client-side + server-side tools)
- Automatic thread management for conversation continuity
- Integration with `Agent` for client-side history management
- Canonical interrupt/resume passthrough (`availableInterrupts` and `resume`)
## Tool Return Helpers
Use `state_update` when a backend tool needs to send different payloads to the model, the UI, and shared state. The `text` value remains the LLM-bound tool result, `tool_result` becomes the AG-UI `ToolCallResultEvent.content` for frontend rendering, and `state` is merged into durable shared state.
```python
from agent_framework import Content, tool
from agent_framework.ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await fetch_weather(city)
return state_update(
text=f"{city}: {data['temp']}°C and {data['conditions']}",
tool_result={
"component": "weather-card",
"city": city,
"temperature": data["temp"],
"conditions": data["conditions"],
"humidity": data["humidity"],
},
state={"weather": {"city": city, **data}},
)
```
## Documentation
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients
- Server setup with FastAPI
- Client examples using `AGUIChatClient`
- Hybrid tool execution (client-side + server-side)
- Thread management and conversation continuity
- **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features
## Interrupts and Resume
Agent Framework AG-UI uses the canonical AG-UI interrupt protocol. Paused agent approval and workflow
`request_info` runs finish with `RUN_FINISHED.outcome.type == "interrupt"` and a non-empty
`RUN_FINISHED.outcome.interrupts` array. Agent Framework does not define a separate interrupt model; use
`ag_ui.core.Interrupt` and `ag_ui.core.ResumeEntry` when constructing typed request data in Python.
Tool approval interrupts use `reason: "tool_call"` and include `toolCallId` when the pause is bound to a tool call.
Workflow `request_info` interrupts use `reason: "input_required"`. Framework-specific details needed for resume
validation live in each interrupt's `metadata`, while generic clients can render the human-readable `message` and
`responseSchema`.
Interrupted terminal event shape:
```json
{
"type": "RUN_FINISHED",
"outcome": {
"type": "interrupt",
"interrupts": [
{
"id": "approval_1",
"reason": "tool_call",
"message": "Approve tool call get_weather?",
"toolCallId": "tool_call_1",
"responseSchema": {
"type": "object",
"properties": {
"accepted": { "type": "boolean" },
"arguments": { "type": "object" }
},
"required": ["accepted"]
},
"metadata": {
"agent_framework": {
"type": "function_approval_request",
"function_call": {
"call_id": "tool_call_1",
"name": "get_weather",
"arguments": {
"city": "Seattle"
}
}
}
}
}
]
}
}
```
Resume the paused thread with a canonical `resume` array. Each entry addresses exactly one open interrupt by
`interruptId`; `status` is `resolved` or `cancelled`; resolved entries carry the approval or workflow response payload.
```json
{
"threadId": "thread-1",
"messages": [],
"resume": [
{
"interruptId": "approval_1",
"status": "resolved",
"payload": {
"approved": true
}
}
]
}
```
This is a clean release-candidate breaking change before `1.0.0`: new interrupted runs use
`RUN_FINISHED.outcome.interrupts` and do not emit a stable top-level `RUN_FINISHED.interrupt` field. Normal
non-interrupted runs continue to finish with valid `RUN_FINISHED` terminal events.
## Public API Review Notes
The Python package is currently in release candidate stage and is targeting the released `1.0.0` API surface. The preferred application import path is `agent_framework.ag_ui`; direct package imports from `agent_framework_ag_ui` are also supported.
Review focus: whether these names are the right stable contract for Python users, and whether the protocol interrupt fields below match AG-UI's expected pause/resume shape.
| Surface | Public exports |
| --- | --- |
| `agent_framework.ag_ui` facade | `AgentFrameworkAgent`, `AgentFrameworkWorkflow`, `AGUIChatClient`, `AGUIEventConverter`, `AGUIHttpService`, `AGUIThreadSnapshot`, `AGUIThreadSnapshotStore`, `InMemoryAGUIThreadSnapshotStore`, `SnapshotScopeResolver`, `add_agent_framework_fastapi_endpoint`, `state_update`, `__version__` |
| Direct `agent_framework_ag_ui` package | Facade exports plus `AGUIChatOptions`, `AGUIRequest`, `AGUIThreadID`, `AgentState`, `DEFAULT_MAX_THREAD_SNAPSHOTS`, `DEFAULT_TAGS`, `PredictStateConfig`, `RunMetadata`, `SnapshotScope`, `WorkflowFactory` |
| AG-UI protocol package (`ag_ui.core`) | `Interrupt`, `ResumeEntry`, `RunFinishedInterruptOutcome`, and related run outcome models |
Interrupt support is protocol data rather than a separate Agent Framework Python class. Requests accept canonical `availableInterrupts`/`available_interrupts` and `resume` values; `AGUIChatClient` and `AGUIHttpService.post_run(...)` forward those fields with AG-UI wire aliases; agent approval and workflow `request_info` pauses emit `RUN_FINISHED.outcome.interrupts`; `AGUIEventConverter` preserves canonical interrupt outcome metadata on the final `ChatResponseUpdate`; and thread snapshot hydration replays the canonical interrupt outcome when a scoped snapshot stores an unresolved pause.
## Features
This integration supports all 7 AG-UI features:
1. **Agentic Chat**: Basic streaming chat with tool calling support
2. **Backend Tool Rendering**: Tools executed on backend with results streamed to client
3. **Human in the Loop**: Function approval requests for user confirmation before tool execution
4. **Agentic Generative UI**: Async tools for long-running operations with progress updates
5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls
6. **Shared State**: Bidirectional state sync between client and server
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
Additional compatibility and draft support:
- Native `Workflow` endpoint registration via `add_agent_framework_fastapi_endpoint(...)`
- Workflow-to-AG-UI event mapping (run/step/activity/tool/custom events)
- Custom event compatibility for inbound `CUSTOM`, `CUSTOM_EVENT`, and `custom_event`
- Pragmatic multimodal input parsing for both legacy (`binary`) and draft media-part shapes
- Canonical interrupt/resume handling (`availableInterrupts`, `resume`, and `RUN_FINISHED.outcome.interrupts`)
## Security: Authentication & Authorization
The AG-UI endpoint does not enforce authentication by default. **For production deployments, you should add authentication** using FastAPI's dependency injection system via the `dependencies` parameter.
### API Key Authentication Example
```python
import os
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
from agent_framework import Agent
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Configure API key authentication
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
EXPECTED_API_KEY = os.environ.get("AG_UI_API_KEY")
async def verify_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> None:
"""Verify the API key provided in the request header."""
if not api_key or api_key != EXPECTED_API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
# Create agent and app
agent = Agent(name="my_agent", instructions="...", client=...)
app = FastAPI()
# Register endpoint WITH authentication
add_agent_framework_fastapi_endpoint(
app,
agent,
"/",
dependencies=[Depends(verify_api_key)], # Authentication enforced here
)
```
### Other Authentication Options
The `dependencies` parameter accepts any FastAPI dependency, enabling integration with:
- **OAuth 2.0 / OpenID Connect** - Use `fastapi.security.OAuth2PasswordBearer`
- **JWT Tokens** - Validate tokens with libraries like `python-jose`
- **Azure AD / Entra ID** - Use `azure-identity` for Microsoft identity platform
- **Rate Limiting** - Add request throttling dependencies
- **Custom Authentication** - Implement your organization's auth requirements
For a complete authentication example, see [getting_started/server.py](getting_started/server.py).
## AG-UI Thread Snapshots
AG-UI Thread Snapshot persistence is opt-in and disabled by default. Existing endpoints keep their current behavior
unless you provide a `snapshot_store`.
Thread snapshots let an AG-UI frontend recover replayable UI state after a refresh. When snapshot persistence is
enabled, the endpoint stores the latest replayable snapshot for an AG-UI Thread within an application-defined
Snapshot Scope. A Hydrate Request is an AG-UI request with a known `threadId`, `messages: []`, and no `resume`
payload. Hydration replays the stored Shared State, message snapshot, and canonical interrupt outcome when available,
then finishes without invoking the wrapped agent or workflow.
Use the built-in in-memory store for local development, demos, and tests:
```python
from fastapi import FastAPI
from agent_framework.ag_ui import InMemoryAGUIThreadSnapshotStore, add_agent_framework_fastapi_endpoint
app = FastAPI()
agent = ...
snapshot_store = InMemoryAGUIThreadSnapshotStore(max_snapshots=500)
def resolve_snapshot_scope(request):
# Local demo scope. Production apps should derive the scope from authenticated user or tenant context.
del request
return "local-demo"
add_agent_framework_fastapi_endpoint(
app,
agent,
"/",
snapshot_store=snapshot_store,
snapshot_scope_resolver=resolve_snapshot_scope,
)
```
A frontend can then hydrate the latest stored snapshot for the scoped thread:
```json
{
"threadId": "thread-1",
"messages": []
}
```
Endpoint configuration requires `snapshot_scope_resolver` whenever a snapshot store is configured, including when
the store is already set on a pre-wrapped `AgentFrameworkAgent` or `AgentFrameworkWorkflow`. The resolver returns
the application-defined Snapshot Scope used with the AG-UI Thread id as the storage key.
AG-UI Thread ids identify AG-UI Threads; they do not authorize snapshot access. Do not treat a thread id as a bearer
credential or tenant boundary. Production applications must authenticate and authorize every AG-UI endpoint request
and choose a Snapshot Scope that represents the app's real access boundary, such as an authenticated user, tenant,
or workspace. Do not rely on untrusted client-provided fields by themselves to choose that boundary.
Tool approval resumes are validated against server-owned Approval State. The default Approval State store is
process-local and bounded, and stores only approval-specific state needed to validate and continue pending approvals.
It is not an authentication, tenant authorization, or distributed durability mechanism; production applications remain
responsible for endpoint authentication, tenant authorization, and deployment/storage architecture that matches their
availability and worker topology requirements.
Stored snapshots are untrusted application data with confidentiality impact. They may contain sensitive user text,
model output, tool results, function arguments, UI payloads, Shared State, and interrupt data. The built-in
`InMemoryAGUIThreadSnapshotStore` is in-memory only, process-local, bounded, latest-only, and not durable production
storage. It is cleared on process restart and is not shared across workers.
No file-backed AG-UI snapshot store is provided by the package. Applications that need durable persistence should
provide an app-owned implementation of the `AGUIThreadSnapshotStore` protocol and own storage hardening, including
encryption, access control, retention, audit, data residency, and deletion behavior.
## Architecture
The package uses a clean, orchestrator-based architecture:
- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators
- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.)
- **Confirmation Strategies**: Domain-specific confirmation messages (extensible)
- **AgentFrameworkEventBridge**: Converts Agent Framework events to AG-UI events
- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats
- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE)
## Next Steps
1. **New to AG-UI?** Start with the [Getting Started Tutorial](getting_started/)
2. **Want to see examples?** Check out the [Examples](agent_framework_ag_ui_examples/) for AG-UI features
## License
MIT
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI protocol integration for Agent Framework."""
import importlib.metadata
from ._agent import AgentFrameworkAgent
from ._client import AGUIChatClient
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._snapshots import (
DEFAULT_MAX_THREAD_SNAPSHOTS,
AGUIThreadID,
AGUIThreadSnapshot,
AGUIThreadSnapshotStore,
InMemoryAGUIThreadSnapshotStore,
SnapshotScope,
SnapshotScopeResolver,
)
from ._state import state_update
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
# Default OpenAPI tags for AG-UI endpoints
DEFAULT_TAGS = ["AG-UI"]
__all__ = [
"AgentFrameworkAgent",
"AgentFrameworkWorkflow",
"WorkflowFactory",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIChatOptions",
"AGUIEventConverter",
"AGUIHttpService",
"AGUIRequest",
"AGUIThreadID",
"AGUIThreadSnapshot",
"AGUIThreadSnapshotStore",
"AgentState",
"InMemoryAGUIThreadSnapshotStore",
"PredictStateConfig",
"RunMetadata",
"SnapshotScope",
"SnapshotScopeResolver",
"DEFAULT_MAX_THREAD_SNAPSHOTS",
"DEFAULT_TAGS",
"state_update",
"__version__",
]
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.
"""AgentFrameworkAgent wrapper for AG-UI protocol."""
from collections.abc import AsyncGenerator
from typing import Any, cast
from ag_ui.core import BaseEvent
from agent_framework import SupportsAgentRun
from ._agent_run import PendingApprovalEntry, PendingApprovalKey, run_agent_stream
from ._approval_state import InMemoryAGUIApprovalStateStore
from ._snapshots import AGUIThreadSnapshotStore
class AgentConfig:
"""Configuration for agent wrapper."""
def __init__(
self,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
use_service_session: bool = False,
require_confirmation: bool = True,
snapshot_store: AGUIThreadSnapshotStore | None = None,
):
"""Initialize agent configuration.
Args:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
use_service_session: Whether the agent session is service-managed
require_confirmation: Whether predictive updates require user confirmation before applying
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
self.state_schema = self._normalize_state_schema(state_schema)
self.predict_state_config = predict_state_config or {}
self.use_service_session = use_service_session
self.require_confirmation = require_confirmation
self.snapshot_store = snapshot_store
@staticmethod
def _normalize_state_schema(state_schema: Any | None) -> dict[str, Any]:
"""Accept dict or Pydantic model/class and return a properties dict."""
if state_schema is None:
return {}
if isinstance(state_schema, dict):
return cast(dict[str, Any], state_schema)
base_model_type: type[Any] | None
try:
from pydantic import BaseModel as ImportedBaseModel
base_model_type = ImportedBaseModel
except Exception: # pragma: no cover
base_model_type = None
if base_model_type is not None and isinstance(state_schema, base_model_type):
schema_dict = state_schema.__class__.model_json_schema()
return schema_dict.get("properties", {}) or {}
if base_model_type is not None and isinstance(state_schema, type) and issubclass(state_schema, base_model_type):
schema_dict = state_schema.model_json_schema() # type: ignore[union-attr]
return schema_dict.get("properties", {}) or {} # type: ignore
return {}
class AgentFrameworkAgent:
"""Wraps Agent Framework agents for AG-UI protocol compatibility.
Translates between Agent Framework's SupportsAgentRun and AG-UI's event-based
protocol. Follows a simple linear flow: RunStarted -> content events -> RunFinished.
"""
def __init__(
self,
agent: SupportsAgentRun,
name: str | None = None,
description: str | None = None,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
use_service_session: bool = False,
snapshot_store: AGUIThreadSnapshotStore | None = None,
):
"""Initialize the AG-UI compatible agent wrapper.
Args:
agent: The Agent Framework agent to wrap
name: Optional name for the agent
description: Optional description
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
require_confirmation: Whether predictive updates require user confirmation before applying
use_service_session: Whether the agent session is service-managed
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
self.agent = agent
self.name = name or getattr(agent, "name", "agent")
self.description = description or getattr(agent, "description", "")
self.config = AgentConfig(
state_schema=state_schema,
predict_state_config=predict_state_config,
use_service_session=use_service_session,
require_confirmation=require_confirmation,
snapshot_store=snapshot_store,
)
# Server-side Approval State. Populated when approval requests are emitted
# and consumed when resume decisions arrive.
self._approval_state_store = InMemoryAGUIApprovalStateStore()
self._pending_approvals = cast(
dict[PendingApprovalKey, PendingApprovalEntry],
self._approval_state_store.pending_approvals,
)
@property
def snapshot_store(self) -> AGUIThreadSnapshotStore | None:
"""Configured AG-UI Thread Snapshot store, if any."""
return self.config.snapshot_store
async def run(
self,
input_data: dict[str, Any],
) -> AsyncGenerator[BaseEvent, None]:
"""Run the wrapped agent and yield AG-UI events.
Args:
input_data: The AG-UI run input containing messages, state, etc.
Yields:
AG-UI events
"""
async for event in run_agent_stream(
input_data,
self.agent,
self.config,
pending_approvals=self._pending_approvals,
approval_state_store=self._approval_state_store,
):
yield event
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Server-side AG-UI approval state storage."""
from __future__ import annotations
from collections import OrderedDict
from typing import Any
ApprovalScope = str
"""Application-defined scope for server-side AG-UI Approval State."""
DEFAULT_MAX_APPROVAL_STATES = 10_000
_APPROVAL_SCOPE_INPUT_KEY = "__ag_ui_approval_scope"
_APPROVAL_THREAD_SEPARATOR = "\x1f"
def approval_state_thread_id(*, scope: object | None, thread_id: str) -> str:
"""Return the storage thread key for Approval State.
``None`` is the only unscoped value. A provided scope must be a non-empty
string so accidental empty or malformed scopes cannot collapse into the
unscoped namespace.
"""
if scope is None:
return thread_id
if not isinstance(scope, str) or not scope:
raise ValueError("scope must be a non-empty string when provided.")
return f"{scope}{_APPROVAL_THREAD_SEPARATOR}{thread_id}"
class InMemoryAGUIApprovalStateStore:
"""Bounded process-local server-side store for AG-UI Approval State.
The default store keeps only pending approval entries. It does not store
general ``AgentSession.state`` or AG-UI Thread Snapshots.
"""
def __init__(self, *, max_entries: int = DEFAULT_MAX_APPROVAL_STATES) -> None:
"""Initialize the process-local Approval State store.
Keyword Args:
max_entries: Maximum pending approval entries to retain.
Raises:
ValueError: If ``max_entries`` is less than 1.
"""
if max_entries < 1:
raise ValueError("max_entries must be greater than 0.")
self.max_entries = max_entries
self.pending_approvals: OrderedDict[tuple[str, str], Any] = OrderedDict()
self.tool_approval_states: OrderedDict[str, dict[str, Any]] = OrderedDict()
def evict_oldest(self) -> None:
"""Evict oldest pending approval entries until the store is within bounds."""
while len(self.pending_approvals) > self.max_entries:
self.pending_approvals.popitem(last=False)
while len(self.tool_approval_states) > self.max_entries:
self.tool_approval_states.popitem(last=False)
@@ -0,0 +1,468 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Chat Client implementation."""
from __future__ import annotations
import json
import logging
import sys
import uuid
from collections.abc import AsyncIterable, Awaitable, Mapping, MutableSequence, Sequence
from functools import wraps
from typing import TYPE_CHECKING, Any, Generic, TypedDict, cast
import httpx
from agent_framework import (
BaseChatClient,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionTool,
Message,
ResponseStream,
)
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from agent_framework.observability import ChatTelemetryLayer
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService, _serialize_available_interrupts, _serialize_resume
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
from typing_extensions import Self, TypedDict # pragma: no cover
if TYPE_CHECKING:
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes
from ._types import AGUIChatOptions
logger: logging.Logger = logging.getLogger("agent_framework.ag_ui")
def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None:
"""Replace server_function_call instances with their underlying call content."""
for idx, content in enumerate(contents):
if content.type == "server_function_call": # type: ignore[union-attr]
contents[idx] = content.function_call # type: ignore[assignment, union-attr]
BaseChatClientT = TypeVar("BaseChatClientT", bound=type[BaseChatClient[Any]])
AGUIChatOptionsT = TypeVar(
"AGUIChatOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="AGUIChatOptions",
covariant=True,
)
def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClientT:
"""Class decorator that unwraps server-side function calls after tool handling."""
original_get_response = client.get_response
@wraps(original_get_response)
def response_wrapper(
self, *args: Any, stream: bool = False, **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
stream_response = original_get_response(self, *args, stream=True, **kwargs)
if isinstance(stream_response, ResponseStream):
return stream_response.with_transform_hook(_map_update)
return ResponseStream(_stream_wrapper_impl(stream_response))
return _response_wrapper_impl(self, original_get_response, *args, **kwargs)
async def _response_wrapper_impl(self, original_func: Any, *args: Any, **kwargs: Any) -> ChatResponse:
"""Non-streaming wrapper implementation."""
response = await original_func(self, *args, stream=False, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], message.contents))
return response
async def _stream_wrapper_impl(stream: Any) -> AsyncIterable[ChatResponseUpdate]:
"""Streaming wrapper implementation."""
if isinstance(stream, Awaitable):
stream = await stream
async for update in stream:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
yield update
def _map_update(update: ChatResponseUpdate) -> ChatResponseUpdate:
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
return update
client.get_response = response_wrapper # type: ignore[assignment]
return client
@_apply_server_function_call_unwrap
class AGUIChatClient(
FunctionInvocationLayer[AGUIChatOptionsT],
ChatMiddlewareLayer[AGUIChatOptionsT],
ChatTelemetryLayer[AGUIChatOptionsT],
BaseChatClient[AGUIChatOptionsT],
Generic[AGUIChatOptionsT],
):
"""Chat client for communicating with AG-UI compliant servers.
This client implements the BaseChatClient interface and automatically handles:
- Thread ID management for conversation continuity
- State synchronization between client and server
- Server-Sent Events (SSE) streaming
- Event conversion to Agent Framework types
- MiddlewareTypes, telemetry, and function invocation support
Important: Message History Management
This client sends exactly the messages it receives to the server. It does NOT
automatically maintain conversation history. The server must handle history via thread_id.
For stateless servers: Use Agent wrapper which will send full message history on each
request. However, even with Agent, the server must echo back all context for the
agent to maintain history across turns.
Important: Tool Handling (Hybrid Execution - matches .NET)
1. Client tool metadata sent to server - LLM knows about both client and server tools
2. Server has its own tools that execute server-side
3. When LLM calls a client tool, function invocation executes it locally
4. Both client and server tools work together (hybrid pattern)
The wrapping Agent's function invocation handles client tool execution
automatically when the server's LLM decides to call them.
Examples:
Direct usage (server manages thread history):
.. code-block:: python
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
# First message - thread ID auto-generated
response = await client.get_response("Hello!")
thread_id = response.additional_properties.get("thread_id")
# Second message - server retrieves history using thread_id
response2 = await client.get_response(
"How are you?",
metadata={"thread_id": thread_id}
)
Recommended usage with Agent (client manages history):
.. code-block:: python
from agent_framework import Agent
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = Agent(name="assistant", client=client)
session = agent.create_session()
# Agent automatically maintains history and sends full context
response = await agent.run("Hello!", session=session)
response2 = await agent.run("How are you?", session=session)
Streaming usage:
.. code-block:: python
async for update in client.get_response("Tell me a story", stream=True):
if update.contents:
for content in update.contents:
if hasattr(content, "text"):
print(content.text, end="", flush=True)
Context manager:
.. code-block:: python
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
response = await client.get_response("Hello!")
print(response.messages[0].text)
Using custom ChatOptions with type safety:
.. code-block:: python
from typing import TypedDict
from agent_framework_ag_ui import AGUIChatClient, AGUIChatOptions
class MyOptions(AGUIChatOptions, total=False):
my_custom_option: str
client: AGUIChatClient[MyOptions] = AGUIChatClient(endpoint="http://localhost:8888/")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
OTEL_PROVIDER_NAME = "agui"
def __init__(
self,
*,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
) -> None:
"""Initialize the AG-UI chat client.
Args:
endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx.AsyncClient instance. If None, one will be created.
timeout: Request timeout in seconds (default: 60.0)
additional_properties: Additional properties to store
middleware: Optional middleware to apply to the client.
function_invocation_configuration: Optional function invocation configuration override.
"""
super().__init__(
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
self._http_service = AGUIHttpService(
endpoint=endpoint,
http_client=http_client,
timeout=timeout,
)
async def close(self) -> None:
"""Close the HTTP client."""
await self._http_service.close()
async def __aenter__(self) -> Self:
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager."""
await self.close()
def _register_server_tool_placeholder(self, tool_name: str) -> None:
"""Register a declaration-only placeholder so function invocation skips execution."""
config = getattr(self, "function_invocation_configuration", None)
if not isinstance(config, dict):
return
additional_tools = list(config.get("additional_tools", []))
if any(getattr(tool, "name", None) == tool_name for tool in additional_tools):
return
placeholder: FunctionTool = FunctionTool(
name=tool_name,
description="Server-managed tool placeholder (AG-UI)",
func=None,
)
additional_tools.append(placeholder)
config["additional_tools"] = additional_tools
registered: set[str] = getattr(self, "_registered_server_tools", set())
registered.add(tool_name)
self._registered_server_tools = registered
logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}")
def _extract_state_from_messages(self, messages: Sequence[Message]) -> tuple[list[Message], dict[str, Any] | None]:
"""Extract state from last message if present.
Args:
messages: List of chat messages
Returns:
Tuple of (messages_without_state, state_dict)
"""
if not messages:
return list(messages), None
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, Content) and content.type == "data" and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"): # type: ignore[union-attr]
import base64
encoded_data = uri.split(",", 1)[1] # type: ignore[union-attr]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
messages_without_state = list(messages[:-1]) if len(messages) > 1 else []
return messages_without_state, state
except (json.JSONDecodeError, ValueError, KeyError) as e:
logger.warning(f"Failed to extract state from message: {e}")
return list(messages), None
def _convert_messages_to_agui_format(self, messages: list[Message]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of Message objects
Returns:
List of AG-UI formatted message dictionaries
"""
return agent_framework_messages_to_agui(messages)
def _get_thread_id(self, options: Mapping[str, Any]) -> str:
"""Get or generate thread ID from chat options.
Args:
options: Chat options containing metadata
Returns:
Thread ID string
"""
thread_id = None
metadata = options.get("metadata")
if metadata:
thread_id = metadata.get("thread_id")
if not thread_id:
thread_id = f"thread_{uuid.uuid4().hex}"
return thread_id
@override
def _inner_get_response(
self,
*,
messages: Sequence[Message],
stream: bool = False,
options: Mapping[str, Any],
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
"""Internal method to get non-streaming response.
Keyword Args:
messages: List of chat messages
stream: Whether to stream the response.
options: Chat options for the request
**kwargs: Additional keyword arguments
Returns:
ChatResponse object
"""
if stream:
return ResponseStream(
self._streaming_impl(
messages=messages,
options=options,
**kwargs,
),
finalizer=ChatResponse.from_updates,
)
async def _get_response() -> ChatResponse:
return await ChatResponse.from_update_generator(
self._streaming_impl(
messages=messages,
options=options,
**kwargs,
)
)
return _get_response()
async def _streaming_impl(
self,
*,
messages: Sequence[Message],
options: Mapping[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Internal method to get streaming response.
Keyword Args:
messages: Sequence of chat messages
options: Chat options for the request
**kwargs: Additional keyword arguments
Yields:
ChatResponseUpdate objects
"""
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(options)
run_id = f"run_{uuid.uuid4().hex}"
agui_messages = self._convert_messages_to_agui_format(messages_to_send)
# Send client tools to server so LLM knows about them
# Client tools execute via Agent's function invocation wrapper
agui_tools = convert_tools_to_agui_format(options.get("tools"))
# Build set of client tool names (matches .NET clientToolSet)
# Used to distinguish client vs server tools in response stream
client_tool_set: set[str] = set()
tools = options.get("tools")
if tools:
for tool in tools:
if hasattr(tool, "name"):
client_tool_set.add(tool.name)
self._last_client_tool_set = client_tool_set
logger.debug(
"[AGUIChatClient] Preparing request",
extra={
"thread_id": thread_id,
"run_id": run_id,
"client_tools": list(client_tool_set),
"messages": [msg.text for msg in messages_to_send if msg.text],
},
)
logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}")
converter = AGUIEventConverter()
available_interrupts = options.get("available_interrupts", options.get("availableInterrupts"))
async for event in self._http_service.post_run(
thread_id=thread_id,
run_id=run_id,
messages=agui_messages,
state=state,
tools=agui_tools,
available_interrupts=_serialize_available_interrupts(cast(Sequence[Any] | None, available_interrupts)),
resume=_serialize_resume(options.get("resume")),
):
logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}")
update = converter.convert_event(event)
if update is not None:
logger.debug(
"[AGUIChatClient] Converted update",
extra={"role": update.role, "contents": [type(c).__name__ for c in update.contents]},
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if content.type == "function_call":
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
)
if content.name in client_tool_set:
# Client tool - let function invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
else:
# Server tool - wrap so function invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name) # type: ignore[arg-type]
update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore
yield update
@@ -0,0 +1,257 @@
# Copyright (c) Microsoft. All rights reserved.
"""FastAPI endpoint creation for AG-UI agents."""
from __future__ import annotations
import copy
import logging
from collections.abc import AsyncGenerator, Sequence
from inspect import isawaitable
from typing import Any, cast
from ag_ui.core import RunErrorEvent
from ag_ui.encoder import EventEncoder
from agent_framework import SupportsAgentRun, Workflow
from fastapi import FastAPI, HTTPException
from fastapi.params import Depends
from fastapi.responses import Response, StreamingResponse
from ._agent import AgentFrameworkAgent
from ._approval_state import _APPROVAL_SCOPE_INPUT_KEY
from ._snapshots import (
_DEFAULT_STATE_INPUT_KEY,
_SNAPSHOT_SCOPE_INPUT_KEY,
AGUIThreadSnapshotStore,
SnapshotScopeResolver,
)
from ._types import AGUIRequest
from ._workflow import AgentFrameworkWorkflow
logger = logging.getLogger(__name__)
_KEEPALIVE_COMMENT = "keepalive"
def _get_snapshot_store(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
) -> AGUIThreadSnapshotStore | None:
if isinstance(protocol_runner, AgentFrameworkAgent):
return protocol_runner.config.snapshot_store
return protocol_runner.snapshot_store
def _set_snapshot_store(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
snapshot_store: AGUIThreadSnapshotStore,
) -> None:
if isinstance(protocol_runner, AgentFrameworkAgent):
protocol_runner.config.snapshot_store = snapshot_store
return
protocol_runner.snapshot_store = snapshot_store
def _configure_snapshot_persistence(
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow,
*,
snapshot_store: AGUIThreadSnapshotStore | None,
snapshot_scope_resolver: SnapshotScopeResolver | None,
) -> None:
existing_snapshot_store = _get_snapshot_store(protocol_runner)
if snapshot_store is not None:
if existing_snapshot_store is not None and existing_snapshot_store is not snapshot_store:
raise ValueError("snapshot_store is already configured on the AG-UI runner.")
if existing_snapshot_store is None:
_set_snapshot_store(protocol_runner, snapshot_store)
existing_snapshot_store = snapshot_store
if existing_snapshot_store is not None and snapshot_scope_resolver is None:
raise ValueError(
"snapshot_scope_resolver is required when snapshot_store is configured. "
"AG-UI Thread ids identify threads but do not authorize snapshot access; "
"provide a resolver that returns an explicit Snapshot Scope."
)
def _validate_keepalive_seconds(keepalive_seconds: float | None) -> None:
if keepalive_seconds is not None and not keepalive_seconds > 0:
raise ValueError("keepalive_seconds must be positive or None.")
def add_agent_framework_fastapi_endpoint(
app: FastAPI,
agent: SupportsAgentRun | AgentFrameworkAgent | Workflow | AgentFrameworkWorkflow,
path: str = "/",
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
allow_origins: list[str] | None = None,
default_state: dict[str, Any] | None = None,
tags: list[str] | None = None,
dependencies: Sequence[Depends] | None = None,
snapshot_store: AGUIThreadSnapshotStore | None = None,
snapshot_scope_resolver: SnapshotScopeResolver | None = None,
keepalive_seconds: float | None = 15,
) -> None:
"""Add an AG-UI endpoint to a FastAPI app.
Args:
app: The FastAPI application
agent: The agent to expose (can be raw SupportsAgentRun or wrapped)
path: The endpoint path
state_schema: Optional state schema for shared state management; accepts dict or Pydantic model/class
predict_state_config: Optional predictive state update configuration.
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
allow_origins: CORS origins (not yet implemented)
default_state: Optional initial state to seed when the client does not provide state keys
tags: OpenAPI tags for endpoint categorization (defaults to ["AG-UI"])
dependencies: Optional FastAPI dependencies for authentication/authorization.
These dependencies run before the endpoint handler. Use this to add
authentication checks, rate limiting, or other middleware-like behavior.
Example: `dependencies=[Depends(verify_api_key)]`
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence is opt-in and requires an
explicit Snapshot Scope resolver.
snapshot_scope_resolver: Optional resolver for the application-defined Snapshot Scope. Required whenever
a snapshot store is configured because an AG-UI Thread id is not an authorization boundary.
keepalive_seconds: Endpoint SSE keepalive interval in seconds. Defaults to 15. Positive values emit fixed
SSE comments while the stream is open. None disables keepalive and preserves the non-keepalive response
path. Keepalive comments are transport traffic and do not change AG-UI events.
"""
_validate_keepalive_seconds(keepalive_seconds)
protocol_runner: AgentFrameworkAgent | AgentFrameworkWorkflow
if isinstance(agent, AgentFrameworkWorkflow):
protocol_runner = agent
elif isinstance(agent, AgentFrameworkAgent):
protocol_runner = agent
elif isinstance(agent, Workflow):
protocol_runner = AgentFrameworkWorkflow(workflow=agent)
elif isinstance(agent, SupportsAgentRun):
protocol_runner = AgentFrameworkAgent(
agent=agent,
state_schema=state_schema,
predict_state_config=predict_state_config,
snapshot_store=snapshot_store,
)
else:
raise TypeError("agent must be SupportsAgentRun, Workflow, AgentFrameworkAgent, or AgentFrameworkWorkflow.")
_configure_snapshot_persistence(
protocol_runner,
snapshot_store=snapshot_store,
snapshot_scope_resolver=snapshot_scope_resolver,
)
@app.post(path, tags=tags or ["AG-UI"], dependencies=dependencies, response_model=None) # type: ignore[arg-type]
async def agent_endpoint(request_body: AGUIRequest) -> Response:
"""Handle AG-UI agent requests.
Note: Function is accessed via FastAPI's decorator registration,
despite appearing unused to static analysis.
"""
try:
input_data = request_body.model_dump(exclude_none=True)
snapshot_persistence_active = False
if snapshot_scope_resolver is not None:
snapshot_scope = snapshot_scope_resolver(request_body)
if isawaitable(snapshot_scope):
snapshot_scope = await snapshot_scope
input_data[_APPROVAL_SCOPE_INPUT_KEY] = snapshot_scope
if _get_snapshot_store(protocol_runner) is not None:
input_data[_SNAPSHOT_SCOPE_INPUT_KEY] = snapshot_scope
snapshot_persistence_active = True
if default_state:
if snapshot_persistence_active:
# Defer default application to the runner so defaults only fill keys
# missing from both the stored snapshot state and the request state.
input_data[_DEFAULT_STATE_INPUT_KEY] = copy.deepcopy(default_state)
else:
state = input_data.setdefault("state", {})
for key, value in default_state.items():
if key not in state:
state[key] = copy.deepcopy(value)
logger.debug(
f"[{path}] Received request - Run ID: {input_data.get('run_id', 'no-run-id')}, "
f"Thread ID: {input_data.get('thread_id', 'no-thread-id')}, "
f"Messages: {len(input_data.get('messages', []))}"
)
logger.info(f"Received request at {path}: {input_data.get('run_id', 'no-run-id')}")
keepalive_enabled = keepalive_seconds is not None
def prepare_frame(encoded: str) -> str | bytes:
if keepalive_enabled:
return encoded.encode("utf-8")
return encoded
async def event_generator() -> AsyncGenerator[str | bytes]:
encoder = EventEncoder()
event_count = 0
try:
async for event in protocol_runner.run(input_data):
event_count += 1
event_type_name = getattr(event, "type", type(event).__name__)
# Log important events at INFO level
if "TOOL_CALL" in str(event_type_name) or "RUN" in str(event_type_name):
if hasattr(event, "model_dump"):
event_data = event.model_dump(exclude_none=True)
logger.info(f"[{path}] Event {event_count}: {event_type_name} - {event_data}")
else:
logger.info(f"[{path}] Event {event_count}: {event_type_name}")
try:
encoded = encoder.encode(event)
except Exception as encode_error:
logger.exception("[%s] Failed to encode event %s", path, event_type_name)
run_error = RunErrorEvent(
message="An internal error has occurred while streaming events.",
code=type(encode_error).__name__,
)
try:
yield prepare_frame(encoder.encode(run_error))
except Exception:
logger.exception("[%s] Failed to encode RUN_ERROR event", path)
return
logger.debug(
f"[{path}] Encoded as: {encoded[:200]}..."
if len(encoded) > 200
else f"[{path}] Encoded as: {encoded}"
)
yield prepare_frame(encoded)
logger.info(f"[{path}] Completed streaming {event_count} events")
except Exception as stream_error:
logger.exception("[%s] Streaming failed", path)
run_error = RunErrorEvent(
message="An internal error has occurred while streaming events.",
code=type(stream_error).__name__,
)
try:
yield prepare_frame(encoder.encode(run_error))
except Exception:
logger.exception("[%s] Failed to encode RUN_ERROR event", path)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
if keepalive_seconds is not None:
from sse_starlette.event import ServerSentEvent
from sse_starlette.sse import EventSourceResponse
return EventSourceResponse(
event_generator(),
ping=cast(int, keepalive_seconds),
ping_message_factory=lambda: ServerSentEvent(comment=_KEEPALIVE_COMMENT),
headers=headers,
media_type="text/event-stream",
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers=headers,
)
except Exception as e:
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="An internal error has occurred.") from e
@@ -0,0 +1,279 @@
# Copyright (c) Microsoft. All rights reserved.
"""Event converter for AG-UI protocol events to Agent Framework types."""
from __future__ import annotations
import logging
from typing import Any
from agent_framework import (
ChatResponseUpdate,
Content,
)
logger = logging.getLogger(__name__)
class AGUIEventConverter:
"""Converter for AG-UI events to Agent Framework types.
Handles conversion of AG-UI protocol events to ChatResponseUpdate objects
while maintaining state, aggregating content, and tracking metadata.
"""
def __init__(self) -> None:
"""Initialize the converter with fresh state."""
self.current_message_id: str | None = None
self.current_tool_call_id: str | None = None
self.current_tool_name: str | None = None
self.accumulated_tool_args: str = ""
self.thread_id: str | None = None
self.run_id: str | None = None
@staticmethod
def _get_tool_call_id(event: dict[str, Any]) -> str | None:
"""Return the tool call ID from either AG-UI field spelling."""
tool_call_id = event.get("toolCallId")
if tool_call_id is None:
tool_call_id = event.get("tool_call_id")
if tool_call_id is None:
return None
return str(tool_call_id)
def convert_event(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Convert a single AG-UI event to ChatResponseUpdate.
Args:
event: AG-UI event dictionary
Returns:
ChatResponseUpdate if event produces content, None otherwise
Examples:
RUN_STARTED event:
.. code-block:: python
converter = AGUIEventConverter()
event = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"}
update = converter.convert_event(event)
assert update.additional_properties["thread_id"] == "t1"
TEXT_MESSAGE_CONTENT event:
.. code-block:: python
event = {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hello"}
update = converter.convert_event(event)
assert update.contents[0].text == "Hello"
"""
raw_event_type = str(event.get("type", ""))
event_type = raw_event_type.upper()
if event_type == "RUN_STARTED":
return self._handle_run_started(event)
elif event_type == "TEXT_MESSAGE_START":
return self._handle_text_message_start(event)
elif event_type == "TEXT_MESSAGE_CONTENT":
return self._handle_text_message_content(event)
elif event_type == "TEXT_MESSAGE_END":
return self._handle_text_message_end(event)
elif event_type == "TOOL_CALL_START":
return self._handle_tool_call_start(event)
elif event_type == "TOOL_CALL_ARGS":
return self._handle_tool_call_args(event)
elif event_type == "TOOL_CALL_END":
return self._handle_tool_call_end(event)
elif event_type == "TOOL_CALL_RESULT":
return self._handle_tool_call_result(event)
elif event_type == "RUN_FINISHED":
return self._handle_run_finished(event)
elif event_type == "RUN_ERROR":
return self._handle_run_error(event)
elif event_type in {"CUSTOM", "CUSTOM_EVENT"}:
return self._handle_custom_event(event, raw_event_type)
return None
def _handle_run_started(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_STARTED event."""
self.thread_id = event.get("threadId")
self.run_id = event.get("runId")
return ChatResponseUpdate(
role="assistant",
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_text_message_start(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_START event."""
self.current_message_id = event.get("messageId")
return ChatResponseUpdate(
role="assistant",
message_id=self.current_message_id,
contents=[],
)
def _handle_text_message_content(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TEXT_MESSAGE_CONTENT event."""
message_id = event.get("messageId")
delta = event.get("delta", "")
if message_id != self.current_message_id:
self.current_message_id = message_id
return ChatResponseUpdate(
role="assistant",
message_id=self.current_message_id,
contents=[Content.from_text(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_END event."""
return None
def _handle_tool_call_start(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_START event."""
self.current_tool_call_id = self._get_tool_call_id(event)
self.current_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name")
self.accumulated_tool_args = ""
return ChatResponseUpdate(
role="assistant",
contents=[
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
)
],
)
def _handle_tool_call_args(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_ARGS event."""
event_tool_call_id = self._get_tool_call_id(event)
if event_tool_call_id is not None:
if self.current_tool_call_id and event_tool_call_id != self.current_tool_call_id:
logger.warning(
"Ignoring TOOL_CALL_ARGS for toolCallId=%s while current toolCallId=%s",
event_tool_call_id,
self.current_tool_call_id,
)
return None
if not self.current_tool_call_id:
self.current_tool_call_id = event_tool_call_id
delta = event.get("delta", "")
self.accumulated_tool_args += delta
return ChatResponseUpdate(
role="assistant",
contents=[
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
)
],
)
def _handle_tool_call_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_END event."""
event_tool_call_id = self._get_tool_call_id(event)
if (
self.current_tool_call_id is None
or event_tool_call_id is None
or event_tool_call_id == self.current_tool_call_id
):
self.current_tool_call_id = None
self.current_tool_name = None
self.accumulated_tool_args = ""
return None
def _handle_tool_call_result(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_RESULT event."""
tool_call_id = event.get("toolCallId", "")
result = event.get("result") if event.get("result") is not None else event.get("content")
return ChatResponseUpdate(
role="tool",
contents=[
Content.from_function_result(
call_id=tool_call_id,
result=result,
)
],
)
def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_FINISHED event."""
additional_properties: dict[str, Any] = {
"thread_id": self.thread_id,
"run_id": self.run_id,
}
if "interrupt" in event:
additional_properties["interrupt"] = event.get("interrupt")
if "outcome" in event:
outcome = event.get("outcome")
additional_properties["outcome"] = outcome
if not isinstance(outcome, dict):
logger.warning(
"RUN_FINISHED outcome should be an object; got %s. Preserving raw outcome.",
type(outcome).__name__,
)
elif outcome.get("type") == "interrupt":
interrupts = outcome.get("interrupts")
if isinstance(interrupts, list):
additional_properties["interrupts"] = interrupts
if "result" in event:
additional_properties["result"] = event.get("result")
return ChatResponseUpdate(
role="assistant",
finish_reason="stop",
contents=[],
additional_properties=additional_properties,
)
def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_ERROR event."""
error_message = event.get("message", "Unknown error")
return ChatResponseUpdate(
role="assistant",
finish_reason="content_filter",
contents=[
Content.from_error(
message=error_message,
error_code="RUN_ERROR",
)
],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_custom_event(self, event: dict[str, Any], raw_event_type: str) -> ChatResponseUpdate:
"""Handle CUSTOM/CUSTOM_EVENT events.
Custom events are surfaced as metadata so callers can inspect protocol-specific payloads.
"""
return ChatResponseUpdate(
role="assistant",
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
"ag_ui_custom_event": {
"name": event.get("name"),
"value": event.get("value"),
"raw_type": raw_event_type,
},
},
)
@@ -0,0 +1,265 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP service for AG-UI protocol communication."""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterable, Mapping, Sequence
from typing import Any, cast
import httpx
from ag_ui.core import Interrupt, ResumeEntry
logger = logging.getLogger(__name__)
def _json_safe_protocol_value(value: Any) -> Any:
"""Convert protocol values to JSON-compatible data using AG-UI aliases."""
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return _json_safe_protocol_value(model_dump(by_alias=True, exclude_none=True))
if isinstance(value, Mapping):
return {key: _json_safe_protocol_value(item) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_json_safe_protocol_value(item) for item in value]
return value
def _serialize_available_interrupts(available_interrupts: Sequence[Any] | None) -> list[dict[str, Any]] | None:
"""Serialize typed or compatible interrupt inputs to canonical AG-UI JSON."""
if available_interrupts is None:
return None
serialized: list[dict[str, Any]] = []
for interrupt in available_interrupts:
if isinstance(interrupt, Mapping) and "reason" not in interrupt:
interrupt = dict(interrupt)
interrupt_type = interrupt.pop("type", None)
if interrupt_type == "request_info" or interrupt_type is None:
interrupt["reason"] = "input_required"
elif isinstance(interrupt_type, str):
interrupt["reason"] = interrupt_type
serialized.append(
cast(dict[str, Any], Interrupt.model_validate(interrupt).model_dump(by_alias=True, exclude_none=True))
)
return serialized
def _serialize_resume_entry(entry: Any) -> dict[str, Any]:
"""Serialize one typed or legacy resume entry to canonical AG-UI JSON."""
model_dump = getattr(entry, "model_dump", None)
if callable(model_dump):
entry = model_dump(by_alias=True, exclude_none=True)
if not isinstance(entry, Mapping):
raise ValueError("Each resume entry must be an object.")
entry_dict = cast(Mapping[str, Any], entry)
interrupt_id = (
entry_dict.get("interruptId")
or entry_dict.get("interrupt_id")
or entry_dict.get("id")
or entry_dict.get("toolCallId")
)
if not interrupt_id:
raise ValueError("Each resume entry must include interruptId.")
status = entry_dict.get("status") or "resolved"
payload = (
entry_dict.get("payload")
if "payload" in entry_dict
else entry_dict.get("value")
if "value" in entry_dict
else entry_dict.get("response")
if "response" in entry_dict
else {
key: value
for key, value in entry_dict.items()
if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"}
}
)
serialized: dict[str, Any] = {"interruptId": str(interrupt_id), "status": str(status)}
if status != "cancelled" or payload:
serialized["payload"] = _json_safe_protocol_value(payload)
return cast(dict[str, Any], ResumeEntry.model_validate(serialized).model_dump(by_alias=True, exclude_none=True))
def _serialize_resume(resume: Any) -> Any: # noqa: ANN401
"""Serialize typed or compatible resume inputs to canonical AG-UI JSON."""
if resume is None:
return None
if isinstance(resume, Sequence) and not isinstance(resume, (str, bytes, bytearray)):
return [_serialize_resume_entry(entry) for entry in resume]
if isinstance(resume, Mapping):
resume_dict = cast(Mapping[str, Any], resume)
if isinstance(resume_dict.get("interrupts"), Sequence) and not isinstance(
resume_dict.get("interrupts"), (str, bytes, bytearray)
):
return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupts"])]
if isinstance(resume_dict.get("interrupt"), Sequence) and not isinstance(
resume_dict.get("interrupt"), (str, bytes, bytearray)
):
return [_serialize_resume_entry(entry) for entry in cast(Sequence[Any], resume_dict["interrupt"])]
if any(key in resume_dict for key in ("interruptId", "interrupt_id", "id", "toolCallId")):
return [_serialize_resume_entry(resume_dict)]
return _json_safe_protocol_value(resume)
class AGUIHttpService:
"""HTTP service for AG-UI protocol communication.
Handles HTTP POST requests and Server-Sent Events (SSE) stream parsing
for the AG-UI protocol.
Examples:
Basic usage:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_123",
run_id="run_456",
messages=[{"role": "user", "content": "Hello"}]
):
print(event["type"])
With context manager:
.. code-block:: python
async with AGUIHttpService("http://localhost:8888/") as service:
async for event in service.post_run(...):
print(event)
"""
def __init__(
self,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
"""Initialize the HTTP service.
Args:
endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx AsyncClient. If None, creates a new one.
timeout: Request timeout in seconds (default: 60.0)
"""
self.endpoint = endpoint.rstrip("/")
self._owns_client = http_client is None
self.http_client = http_client or httpx.AsyncClient(timeout=timeout)
async def post_run(
self,
thread_id: str,
run_id: str,
messages: list[dict[str, Any]],
state: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
available_interrupts: Sequence[Any] | None = None,
resume: Any = None,
) -> AsyncIterable[dict[str, Any]]:
"""Post a run request and stream AG-UI events.
Args:
thread_id: Thread identifier for conversation continuity
run_id: Unique run identifier
messages: List of messages in AG-UI format
state: Optional state object to send to server
tools: Optional list of tools available to the agent
available_interrupts: Optional list of interrupt descriptors available for resumption
resume: Optional resume payload to continue a paused run
Yields:
AG-UI event dictionaries parsed from SSE stream
Raises:
httpx.HTTPStatusError: If the HTTP request fails
ValueError: If SSE parsing encounters invalid data
Examples:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_abc",
run_id="run_123",
messages=[{"role": "user", "content": "Hello"}],
state={"user_context": {"name": "Alice"}}
):
if event["type"] == "TEXT_MESSAGE_CONTENT":
print(event["delta"])
"""
# Build request payload
request_data: dict[str, Any] = {
"thread_id": thread_id,
"run_id": run_id,
"messages": messages,
}
if state is not None:
request_data["state"] = state
if tools is not None:
request_data["tools"] = tools
serialized_available_interrupts = _serialize_available_interrupts(available_interrupts)
if serialized_available_interrupts is not None:
request_data["availableInterrupts"] = serialized_available_interrupts
serialized_resume = _serialize_resume(resume)
if serialized_resume is not None:
request_data["resume"] = serialized_resume
logger.debug(
f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, "
f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}, "
f"has_available_interrupts={available_interrupts is not None}, has_resume={resume is not None}"
)
# Stream the response using SSE
async with self.http_client.stream(
"POST",
self.endpoint,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP request failed: {e.response.status_code} - {e.response.text}")
raise
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
logger.debug(f"Received event: {event.get('type', 'UNKNOWN')}")
yield event
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {data}. Error: {e}")
# Continue processing other events instead of failing
continue
async def close(self) -> None:
"""Close the HTTP client if owned by this service.
Only closes the client if it was created by this service instance.
If an external client was provided, it remains the caller's
responsibility to close it.
"""
if self._owns_client and self.http_client:
await self.http_client.aclose()
async def __aenter__(self) -> AGUIHttpService:
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager and clean up resources."""
await self.close()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,247 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helper functions for orchestration logic.
This module retains utilities that may be useful for testing or extensions.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent_framework import (
Content,
Message,
)
from .._utils import get_role_value
logger = logging.getLogger(__name__)
def pending_tool_call_ids(messages: list[Message]) -> set[str]:
"""Get IDs of tool calls without corresponding results.
Args:
messages: List of messages to scan
Returns:
Set of pending tool call IDs
"""
pending_ids: set[str] = set()
resolved_ids: set[str] = set()
for msg in messages:
for content in msg.contents:
if content.type == "function_call" and content.call_id:
pending_ids.add(str(content.call_id))
elif content.type == "function_result" and content.call_id:
resolved_ids.add(str(content.call_id))
return pending_ids - resolved_ids
def is_state_context_message(message: Message) -> bool:
"""Check if a message is a state context system message.
Args:
message: Message to check
Returns:
True if this is a state context message
"""
if get_role_value(message) != "system":
return False
for content in message.contents:
if content.type == "text" and content.text.startswith("Current state of the application:"): # type: ignore[union-attr]
return True
return False
def ensure_tool_call_entry(
tool_call_id: str,
tool_calls_by_id: dict[str, dict[str, Any]],
pending_tool_calls: list[dict[str, Any]],
) -> dict[str, Any]:
"""Get or create a tool call entry in the tracking dicts.
Args:
tool_call_id: The tool call ID
tool_calls_by_id: Dict mapping IDs to tool call entries
pending_tool_calls: List of pending tool calls
Returns:
The tool call entry dict
"""
entry = tool_calls_by_id.get(tool_call_id)
if entry is None:
entry = {
"id": tool_call_id,
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
tool_calls_by_id[tool_call_id] = entry
pending_tool_calls.append(entry)
return entry
def tool_name_for_call_id(
tool_calls_by_id: dict[str, dict[str, Any]],
tool_call_id: str,
) -> str | None:
"""Get the tool name for a given call ID.
Args:
tool_calls_by_id: Dict mapping IDs to tool call entries
tool_call_id: The tool call ID to look up
Returns:
Tool name or None if not found
"""
entry = tool_calls_by_id.get(tool_call_id)
if not entry:
return None
function = entry.get("function")
if not isinstance(function, dict):
return None
name = function.get("name")
return str(name) if name else None
def schema_has_steps(schema: Any) -> bool:
"""Check if a schema has a steps array property.
Args:
schema: JSON schema to check
Returns:
True if schema has steps array
"""
if not isinstance(schema, dict):
return False
properties = schema.get("properties")
if not isinstance(properties, dict):
return False
steps_schema = properties.get("steps")
if not isinstance(steps_schema, dict):
return False
return steps_schema.get("type") == "array"
def select_approval_tool_name(client_tools: list[Any] | None) -> str | None:
"""Select appropriate approval tool from client tools.
Args:
client_tools: List of client tool definitions
Returns:
Name of approval tool, or None if not found
"""
if not client_tools:
return None
for tool in client_tools:
tool_name = getattr(tool, "name", None)
if not tool_name:
continue
params_fn = getattr(tool, "parameters", None)
if not callable(params_fn):
continue
schema = params_fn()
if schema_has_steps(schema):
return str(tool_name)
return None
def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Build metadata dict with truncated string values for Azure compatibility.
Azure has a 512 character limit per metadata value.
Args:
thread_metadata: Raw metadata dict
Returns:
Metadata with string values truncated to 512 chars
"""
if not thread_metadata:
return {}
safe_metadata: dict[str, Any] = {}
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
safe_metadata[key] = value_str
return safe_metadata
def latest_approval_response(messages: list[Message]) -> Content | None:
"""Get the latest approval response from messages.
Args:
messages: Messages to search
Returns:
Latest approval response or None
"""
if not messages:
return None
last_message = messages[-1]
for content in last_message.contents:
if content.type == "function_approval_response":
return content
return None
def approval_steps(approval: Content) -> list[Any]:
"""Extract steps from an approval response.
Args:
approval: Approval response content
Returns:
List of steps, or empty list if none
"""
state_args = approval.additional_properties.get("ag_ui_state_args", None)
if isinstance(state_args, dict):
steps = state_args.get("steps")
if isinstance(steps, list):
return steps
if approval.function_call:
parsed_args = approval.function_call.parse_arguments()
if isinstance(parsed_args, dict):
steps = parsed_args.get("steps")
if isinstance(steps, list):
return steps
return []
def is_step_based_approval(
approval: Content,
predict_state_config: dict[str, dict[str, str]] | None,
) -> bool:
"""Check if an approval is step-based.
Args:
approval: Approval response to check
predict_state_config: Predictive state configuration
Returns:
True if this is a step-based approval
"""
steps = approval_steps(approval)
if steps:
return True
if not approval.function_call:
return False
if not predict_state_config:
return False
tool_name = approval.function_call.name
for config in predict_state_config.values():
if config.get("tool") == tool_name and config.get("tool_argument") == "steps":
return True
return False
@@ -0,0 +1,232 @@
# Copyright (c) Microsoft. All rights reserved.
"""Predictive state handling utilities."""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from ag_ui.core import StateDeltaEvent
from .._utils import safe_json_parse
logger = logging.getLogger(__name__)
class PredictiveStateHandler:
"""Handles predictive state updates from streaming tool calls."""
def __init__(
self,
predict_state_config: dict[str, dict[str, str]] | None = None,
current_state: dict[str, Any] | None = None,
) -> None:
"""Initialize the handler.
Args:
predict_state_config: Configuration mapping state keys to tool/argument pairs
current_state: Reference to current state dict
"""
self.predict_state_config = predict_state_config or {}
self.current_state = current_state or {}
self.streaming_tool_args: str = ""
self.last_emitted_state: dict[str, Any] = {}
self.state_delta_count: int = 0
self.pending_state_updates: dict[str, Any] = {}
def reset_streaming(self) -> None:
"""Reset streaming state for a new tool call."""
self.streaming_tool_args = ""
self.state_delta_count = 0
def extract_state_value(
self,
tool_name: str,
args: dict[str, Any] | str | None,
) -> tuple[str, Any] | None:
"""Extract state value from tool arguments based on config.
Args:
tool_name: Name of the tool being called
args: Tool arguments (dict or JSON string)
Returns:
Tuple of (state_key, state_value) or None if no match
"""
if not self.predict_state_config:
return None
parsed_args = safe_json_parse(args) if isinstance(args, str) else args
if not parsed_args:
return None
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
return (state_key, parsed_args)
if tool_arg_name in parsed_args:
return (state_key, parsed_args[tool_arg_name])
return None
def is_predictive_tool(self, tool_name: str | None) -> bool:
"""Check if a tool is configured for predictive state.
Args:
tool_name: Name of the tool to check
Returns:
True if tool is in predictive state config
"""
if not tool_name or not self.predict_state_config:
return False
for config in self.predict_state_config.values():
if config["tool"] == tool_name:
return True
return False
def emit_streaming_deltas(
self,
tool_name: str | None,
argument_chunk: str,
) -> list[StateDeltaEvent]:
"""Process streaming argument chunk and emit state deltas.
Args:
tool_name: Name of the current tool
argument_chunk: New chunk of JSON arguments
Returns:
List of state delta events to emit
"""
events: list[StateDeltaEvent] = []
if not tool_name or not self.predict_state_config:
return events
self.streaming_tool_args += argument_chunk
logger.debug(
"Predictive state: accumulated %s chars for tool '%s'",
len(self.streaming_tool_args),
tool_name,
)
# Try to parse complete JSON first
parsed_args = None
try:
parsed_args = json.loads(self.streaming_tool_args)
except json.JSONDecodeError:
# Fall back to regex matching for partial JSON
events.extend(self._emit_partial_deltas(tool_name))
if parsed_args:
events.extend(self._emit_complete_deltas(tool_name, parsed_args))
return events
def _emit_partial_deltas(self, tool_name: str) -> list[StateDeltaEvent]:
"""Emit deltas from partial JSON using regex matching.
Args:
tool_name: Name of the current tool
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
match = re.search(pattern, self.streaming_tool_args)
if match:
partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value:
event = self._create_delta_event(state_key, partial_value)
events.append(event)
self.last_emitted_state[state_key] = partial_value
self.pending_state_updates[state_key] = partial_value
return events
def _emit_complete_deltas(
self,
tool_name: str,
parsed_args: dict[str, Any],
) -> list[StateDeltaEvent]:
"""Emit deltas from complete parsed JSON.
Args:
tool_name: Name of the current tool
parsed_args: Fully parsed arguments dict
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
continue
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
event = self._create_delta_event(state_key, state_value)
events.append(event)
self.last_emitted_state[state_key] = state_value
self.pending_state_updates[state_key] = state_value
return events
def _create_delta_event(self, state_key: str, value: Any) -> StateDeltaEvent:
"""Create a state delta event with logging.
Args:
state_key: The state key being updated
value: The new value
Returns:
StateDeltaEvent instance
"""
self.state_delta_count += 1
if self.state_delta_count % 10 == 1:
logger.info(
"StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s",
self.state_delta_count,
state_key,
state_key,
len(str(value)),
)
elif self.state_delta_count % 100 == 0:
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
return StateDeltaEvent(
delta=[
{
"op": "replace",
"path": f"/{state_key}",
"value": value,
}
],
)
def apply_pending_updates(self) -> None:
"""Apply pending updates to current state and clear them."""
for key, value in self.pending_state_updates.items():
self.current_state[key] = value
self.pending_state_updates.clear()
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool handling helpers."""
from __future__ import annotations
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
logger = logging.getLogger(__name__)
def _collect_mcp_tool_functions(mcp_tools: list[Any]) -> list[Any]:
"""Extract functions from connected MCP tools.
Args:
mcp_tools: List of MCP tool instances.
Returns:
Functions from connected MCP tools.
"""
functions: list[Any] = []
for mcp_tool in mcp_tools:
if getattr(mcp_tool, "is_connected", False) and hasattr(mcp_tool, "functions"):
functions.extend(mcp_tool.functions)
return functions
def collect_server_tools(agent: SupportsAgentRun) -> list[Any]:
"""Collect server tools from an agent.
This includes both regular tools from default_options and MCP tools.
MCP tools are stored separately for lifecycle management but their
functions need to be included for tool execution during approval flows.
Args:
agent: Agent instance to collect tools from. Works with Agent
or any agent with default_options and optional mcp_tools attributes.
Returns:
List of tools including both regular tools and connected MCP tool functions.
"""
# Get tools from default_options
default_options = getattr(agent, "default_options", None)
if default_options is None:
return []
tools_from_agent = default_options.get("tools") if isinstance(default_options, dict) else None
server_tools = list(tools_from_agent) if tools_from_agent else []
# Include functions from connected MCP tools (only available on Agent)
mcp_tools = getattr(agent, "mcp_tools", None)
if 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:
tool_name = getattr(tool, "name", "unknown")
approval_mode = getattr(tool, "approval_mode", None)
logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}")
return server_tools
def register_additional_client_tools(agent: SupportsAgentRun, client_tools: list[Any] | None) -> None:
"""Register client tools as additional declaration-only tools to avoid server execution.
Args:
agent: Agent instance to register tools on. Works with Agent
or any agent with a client attribute.
client_tools: List of client tools to register.
"""
if not client_tools:
return
client = getattr(agent, "client", None)
if client is None:
return
if isinstance(client, BaseChatClient) and client.function_invocation_configuration is not None: # type: ignore[attr-defined]
client.function_invocation_configuration["additional_tools"] = client_tools # type: ignore[attr-defined]
logger.debug(f"[TOOLS] Registered {len(client_tools)} client tools as additional_tools (declaration-only)")
def _has_approval_tools(tools: list[Any]) -> bool:
"""Check if any tools require approval."""
return any(getattr(tool, "approval_mode", None) == "always_require" for tool in tools)
def merge_tools(server_tools: list[Any], client_tools: list[Any] | None) -> list[Any] | None:
"""Combine server and client tools without overriding server metadata.
IMPORTANT: When server tools have approval_mode="always_require", we MUST return
them so they get passed to the streaming response handler. Otherwise, the approval
check in _try_execute_function_calls won't find the tool and won't trigger approval.
"""
if not client_tools:
# Even without client tools, we must pass server tools if any require approval
if server_tools and _has_approval_tools(server_tools):
logger.info(
f"[TOOLS] No client tools but server has approval tools - "
f"passing {len(server_tools)} server tools for approval mode"
)
return server_tools
logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)")
return None
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(client_tools)} client)"
)
return combined_tools
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Thread Snapshot storage primitives."""
from __future__ import annotations
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, runtime_checkable
if TYPE_CHECKING:
from ._types import AGUIRequest
SnapshotScope: TypeAlias = str
"""Application-defined scope for authorizing access to AG-UI Thread Snapshots."""
AGUIThreadID: TypeAlias = str
"""AG-UI Thread identifier within a Snapshot Scope."""
SnapshotScopeResolver: TypeAlias = Callable[["AGUIRequest"], str | Awaitable[str]]
"""Callable that resolves the Snapshot Scope for an AG-UI endpoint request."""
_SnapshotKey: TypeAlias = tuple[SnapshotScope, AGUIThreadID]
DEFAULT_MAX_THREAD_SNAPSHOTS = 1_000
_SNAPSHOT_SCOPE_INPUT_KEY = "__ag_ui_snapshot_scope"
_DEFAULT_STATE_INPUT_KEY = "__ag_ui_default_state"
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class AGUIThreadSnapshot:
"""Replayable AG-UI Thread state.
AG-UI Thread Snapshots intentionally contain only data that can be replayed
to a UI: message snapshots, optional Shared State, and optional interruption
state. They do not include raw events, request metadata, auth claims,
diagnostics, traces, or provider responses.
Attributes:
messages: Replayable AG-UI message snapshots.
state: Optional AG-UI Shared State snapshot.
interrupt: Optional interruption state from ``RUN_FINISHED.outcome.interrupts``.
"""
messages: list[dict[str, Any]] = field(default_factory=list)
state: dict[str, Any] | None = None
interrupt: list[dict[str, Any]] | None = None
@runtime_checkable
class AGUIThreadSnapshotStore(Protocol):
"""Async store for latest AG-UI Thread Snapshots keyed by scope and thread id."""
async def save(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
snapshot: AGUIThreadSnapshot,
) -> None:
"""Save the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope. This is part of the
storage key and must represent the app's authorization boundary.
thread_id: AG-UI Thread id within the scope.
snapshot: Snapshot to save.
"""
...
async def get(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> AGUIThreadSnapshot | None:
"""Get the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope.
thread_id: AG-UI Thread id within the scope.
Returns:
The latest snapshot, or ``None`` when no snapshot exists for the key.
"""
...
async def delete(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> bool:
"""Delete the latest snapshot for an AG-UI Thread within a Snapshot Scope.
Args:
scope: Application-defined Snapshot Scope.
thread_id: AG-UI Thread id within the scope.
Returns:
``True`` when a snapshot was deleted, otherwise ``False``.
"""
...
async def clear(self, *, scope: SnapshotScope | None = None) -> None:
"""Clear saved snapshots.
Args:
scope: Optional Snapshot Scope to clear. When omitted, all in-memory
snapshots are cleared.
"""
...
class InMemoryAGUIThreadSnapshotStore:
"""Bounded memory-only latest snapshot store for local development, demos, and tests.
This store keeps at most one snapshot per ``(scope, thread_id)`` key. It is
process-local and not durable production storage.
"""
def __init__(self, *, max_snapshots: int = DEFAULT_MAX_THREAD_SNAPSHOTS) -> None:
"""Initialize the in-memory snapshot store.
Keyword Args:
max_snapshots: Maximum number of scoped thread snapshots to retain.
Raises:
ValueError: If ``max_snapshots`` is less than 1.
"""
if max_snapshots < 1:
raise ValueError("max_snapshots must be greater than 0.")
self._max_snapshots = max_snapshots
self._snapshots: dict[_SnapshotKey, AGUIThreadSnapshot] = {}
async def save(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
snapshot: AGUIThreadSnapshot,
) -> None:
"""Save the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
key = self._key(scope=scope, thread_id=thread_id)
if key in self._snapshots:
del self._snapshots[key]
self._snapshots[key] = copy.deepcopy(snapshot)
self._evict_oldest()
async def get(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> AGUIThreadSnapshot | None:
"""Get the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
snapshot = self._snapshots.get(self._key(scope=scope, thread_id=thread_id))
return copy.deepcopy(snapshot) if snapshot is not None else None
async def delete(
self,
*,
scope: SnapshotScope,
thread_id: AGUIThreadID,
) -> bool:
"""Delete the latest snapshot for an AG-UI Thread within a Snapshot Scope."""
key = self._key(scope=scope, thread_id=thread_id)
if key not in self._snapshots:
return False
del self._snapshots[key]
return True
async def clear(self, *, scope: SnapshotScope | None = None) -> None:
"""Clear saved snapshots, optionally limited to one Snapshot Scope."""
if scope is None:
self._snapshots.clear()
return
normalized_scope = self._normalize_key_part(scope, "scope")
for key in list(self._snapshots):
if key[0] == normalized_scope:
del self._snapshots[key]
@classmethod
def _key(cls, *, scope: SnapshotScope, thread_id: AGUIThreadID) -> _SnapshotKey:
return (
cls._normalize_key_part(scope, "scope"),
cls._normalize_key_part(thread_id, "thread_id"),
)
@staticmethod
def _normalize_key_part(value: str, name: str) -> str:
if not isinstance(value, str):
raise TypeError(f"{name} must be a string.")
if not value:
raise ValueError(f"{name} must be a non-empty string.")
return value
def _evict_oldest(self) -> None:
while len(self._snapshots) > self._max_snapshots:
del self._snapshots[next(iter(self._snapshots))]
async def _clear_thread_snapshot_interrupt(
*,
snapshot_store: AGUIThreadSnapshotStore,
scope: SnapshotScope,
thread_id: AGUIThreadID,
interrupt_ids: set[str] | None = None,
) -> None:
"""Clear completed interruption state from the latest replayable thread snapshot."""
try:
snapshot = await snapshot_store.get(scope=scope, thread_id=thread_id)
if snapshot is None or snapshot.interrupt is None:
return
if interrupt_ids is None:
snapshot.interrupt = None
else:
remaining_interrupts = [
interrupt
for interrupt in snapshot.interrupt
if str(interrupt.get("id") or interrupt.get("interruptId")) not in interrupt_ids
]
snapshot.interrupt = remaining_interrupts or None
await snapshot_store.save(scope=scope, thread_id=thread_id, snapshot=snapshot)
except Exception:
logger.exception(
"Failed to clear AG-UI Thread Snapshot interrupt for scope=%s thread_id=%s; keeping previous snapshot.",
scope,
thread_id,
)
@@ -0,0 +1,137 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deterministic tool-driven AG-UI state updates and display payloads.
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
deterministic state update or a per-call tool result display payload by
returning :func:`state_update`. Unlike ``predict_state_config`` — which emits
``StateDeltaEvent``s optimistically from LLM-predicted tool call arguments —
``state_update`` runs *after* the tool executes, so AG-UI state and display
content always reflect the tool's actual return value.
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
motivating discussion.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from agent_framework import Content
from ._utils import make_json_safe
__all__ = ["TOOL_RESULT_DISPLAY_KEY", "TOOL_RESULT_STATE_KEY", "state_update"]
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
state snapshot from a tool return value through to the AG-UI emitter."""
TOOL_RESULT_DISPLAY_KEY = "__ag_ui_tool_result_display__"
"""Reserved ``Content.additional_properties`` key used to carry UI-only tool result display content from a tool return value through to the AG-UI emitter."""
_UNSET = object()
def _serialize_tool_result(value: Any) -> str: # noqa: ANN401
return value if isinstance(value, str) else json.dumps(make_json_safe(value))
def state_update(
text: str = "",
*,
state: Mapping[str, Any] | None = None,
tool_result: Any = _UNSET, # noqa: ANN401
) -> Content:
"""Build a tool return value that updates AG-UI shared state or display content.
Return the result of this helper from an agent tool to push a state update
or UI-only display payload to AG-UI clients using the actual tool output,
rather than LLM-predicted tool arguments.
When the AG-UI endpoint emits the tool result, it will:
* Forward ``text`` to the LLM as the normal ``function_result`` content.
* Use ``tool_result`` as the ``ToolCallResultEvent.content`` payload shown
to AG-UI clients, falling back to ``text`` when no display payload is set.
* Merge ``state`` into ``FlowState.current_state``.
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
event so frontends observe the updated state deterministically. If
predictive state is enabled, a predictive snapshot may be emitted first.
Example:
.. code-block:: python
from agent_framework import Content, tool
from agent_framework_ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await _fetch_weather(city)
return state_update(
text=f"Weather in {city}: {data['temp']}°C {data['conditions']}",
state={"weather": {"city": city, **data}},
)
Example:
.. code-block:: python
from agent_framework import Content, tool
from agent_framework_ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await _fetch_weather(city)
return state_update(
text=f"{city}: {data['temp']}°C and {data['conditions']}",
tool_result={
"component": "weather-card",
"city": city,
"temperature": data["temp"],
"conditions": data["conditions"],
"humidity": data["humidity"],
},
state={"weather": {"city": city, **data}},
)
Args:
text: Text passed back to the LLM as the ``function_result`` content.
Defaults to an empty string for tools whose only output is a state
update.
state: A mapping merged into the AG-UI shared state via JSON-compatible
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
tool_result: JSON-safe payload emitted to AG-UI clients as
``ToolCallResultEvent.content`` for frontend rendering. The LLM
still receives ``text``. If ``text`` is empty, the serialized
display payload is also used as the LLM-bound text fallback.
Returns:
A ``Content`` object with ``type="text"``. The state payload rides in
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY`
(``"__ag_ui_tool_result_state__"``), and the display payload rides
under :data:`TOOL_RESULT_DISPLAY_KEY`
(``"__ag_ui_tool_result_display__"``). Both reserved keys are extracted
by the AG-UI emitter.
Raises:
TypeError: If ``state`` is not a ``Mapping``.
"""
if state is not None and not isinstance(state, Mapping):
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
additional_properties: dict[str, Any] = {}
if state is not None:
additional_properties[TOOL_RESULT_STATE_KEY] = dict(state)
if tool_result is not _UNSET:
display_content = _serialize_tool_result(tool_result)
additional_properties[TOOL_RESULT_DISPLAY_KEY] = display_content
if not text:
text = display_content
return Content.from_text(
text,
additional_properties=additional_properties,
)
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Type definitions for AG-UI integration."""
from __future__ import annotations
import sys
from typing import Annotated, Any, Generic
from ag_ui.core import Interrupt, ResumeEntry
from agent_framework import ChatOptions
from pydantic import AliasChoices, BaseModel, BeforeValidator, Field
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
AGUIChatOptionsT = TypeVar("AGUIChatOptionsT", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type]
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
def _coerce_legacy_resume_entry(value: Any) -> Any: # noqa: ANN401
if not isinstance(value, dict):
return value
interrupt_id = value.get("interruptId") or value.get("interrupt_id") or value.get("id") or value.get("toolCallId")
if not interrupt_id:
return value
if "payload" in value:
payload = value.get("payload")
elif "value" in value:
payload = value.get("value")
elif "response" in value:
payload = value.get("response")
else:
payload = {
key: item
for key, item in value.items()
if key not in {"id", "interruptId", "interrupt_id", "toolCallId", "type", "status"}
}
entry: dict[str, Any] = {"interruptId": str(interrupt_id), "status": value.get("status", "resolved")}
if payload is not None:
entry["payload"] = payload
return entry
def _coerce_legacy_resume(value: Any) -> Any: # noqa: ANN401
if value is None:
return value
if isinstance(value, dict):
if "interrupts" in value:
value = value["interrupts"]
elif "interrupt" in value:
value = value["interrupt"]
elif any(key in value for key in ("interruptId", "interrupt_id", "id", "toolCallId")):
value = [value]
else:
return value
if not isinstance(value, list):
return value
return [_coerce_legacy_resume_entry(entry) for entry in value]
class PredictStateConfig(TypedDict):
"""Configuration for predictive state updates."""
state_key: str
tool: str
tool_argument: str | None
class RunMetadata(TypedDict):
"""Metadata for agent run."""
run_id: str
thread_id: str
predict_state: list[PredictStateConfig] | None
class AgentState(TypedDict):
"""Base state for AG-UI agents."""
messages: list[Any] | None
class AGUIRequest(BaseModel):
"""Request model for AG-UI endpoints."""
messages: list[dict[str, Any]] = Field(
...,
description="AG-UI format messages array",
)
run_id: str | None = Field(
None,
validation_alias=AliasChoices("run_id", "runId"),
description="Optional run identifier for tracking",
)
thread_id: str | None = Field(
None,
validation_alias=AliasChoices("thread_id", "threadId"),
description="Optional thread identifier for conversation context",
)
state: dict[str, Any] | None = Field(
None,
description="Optional shared state for agentic generative UI",
)
tools: list[dict[str, Any]] | None = Field(
None,
description="Client-side tools to advertise to the LLM",
)
context: list[dict[str, Any]] | None = Field(
None,
description="List of context objects provided to the agent",
)
forwarded_props: dict[str, Any] | None = Field(
None,
validation_alias=AliasChoices("forwarded_props", "forwardedProps"),
description="Additional properties forwarded to the agent",
)
parent_run_id: str | None = Field(
None,
validation_alias=AliasChoices("parent_run_id", "parentRunId"),
description="ID of the run that spawned this run",
)
available_interrupts: list[Interrupt] | None = Field(
None,
validation_alias=AliasChoices("availableInterrupts", "available_interrupts"),
description="Canonical AG-UI interrupts that can be resumed by the server",
)
resume: Annotated[list[ResumeEntry], BeforeValidator(_coerce_legacy_resume)] | None = Field(
None,
description="Resume payload for continuing interrupted runs",
)
# region AG-UI Chat Options TypedDict
class AGUIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False):
"""AG-UI protocol-specific chat options dict.
Extends base ChatOptions for the AG-UI (Agent-UI) protocol.
AG-UI is a streaming protocol for connecting AI agents to user interfaces.
Options are forwarded to the remote AG-UI server.
See: https://github.com/ag-ui/ag-ui-protocol
Keys:
# Inherited from ChatOptions (forwarded to remote server):
model: The model identifier (forwarded as-is to server).
temperature: Sampling temperature.
top_p: Nucleus sampling parameter.
max_tokens: Maximum tokens to generate.
stop: Stop sequences.
tools: List of tools - sent to server so LLM knows about client tools.
Server executes its own tools; client tools execute locally via
function invocation middleware.
tool_choice: How the model should use tools.
metadata: Metadata dict containing thread_id for conversation continuity.
# Options with limited support (depends on remote server):
frequency_penalty: Forwarded if remote server supports it.
presence_penalty: Forwarded if remote server supports it.
seed: Forwarded if remote server supports it.
response_format: Forwarded if remote server supports it.
logit_bias: Forwarded if remote server supports it.
user: Forwarded if remote server supports it.
# Options not typically used in AG-UI:
store: Not applicable for AG-UI protocol.
allow_multiple_tool_calls: Handled by underlying server.
# AG-UI-specific options:
forward_props: Additional properties to forward to the AG-UI server.
Useful for passing custom parameters to specific server implementations.
context: Shared context/state to send to the server.
Note:
AG-UI is a protocol bridge - actual option support depends on the
remote server implementation. The client sends all options to the
server, which decides how to handle them.
Thread ID management:
- Pass ``thread_id`` in ``metadata`` to maintain conversation continuity
- If not provided, a new thread ID is auto-generated
"""
# AG-UI-specific options
forward_props: dict[str, Any]
"""Additional properties to forward to the AG-UI server."""
context: dict[str, Any]
"""Shared context/state to send to the server."""
available_interrupts: list[Interrupt]
"""Canonical AG-UI interrupt descriptors available for resumption."""
resume: list[ResumeEntry]
"""Canonical AG-UI resume entries to continue a paused run."""
# ChatOptions fields not applicable for AG-UI
store: None # type: ignore[misc]
"""Not applicable for AG-UI protocol."""
AGUI_OPTION_TRANSLATIONS: dict[str, str] = {}
"""Maps ChatOptions keys to AG-UI parameter names (protocol uses standard names)."""
# endregion
@@ -0,0 +1,292 @@
# Copyright (c) Microsoft. All rights reserved.
"""Utility functions for AG-UI integration."""
from __future__ import annotations
import copy
import json
import uuid
from collections.abc import Callable, MutableMapping, Sequence
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any
from agent_framework import AgentResponseUpdate, ChatResponseUpdate, FunctionTool
# Role mapping constants
AGUI_TO_FRAMEWORK_ROLE: dict[str, str] = {
"user": "user",
"assistant": "assistant",
"system": "system",
}
FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = {
"user": "user",
"assistant": "assistant",
"system": "system",
}
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"}
def generate_event_id() -> str:
"""Generate a unique event ID."""
return str(uuid.uuid4())
def safe_json_parse(value: Any) -> dict[str, Any] | None:
"""Safely parse a value as JSON dict.
Args:
value: String or dict to parse
Returns:
Parsed dict or None if parsing fails
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return None
def canonical_function_arguments(function_call: Any) -> str | None:
"""Return a stable representation of function-call arguments."""
if function_call is None:
return None
try:
parsed_arguments = function_call.parse_arguments()
except Exception:
parsed_arguments = getattr(function_call, "arguments", None)
if parsed_arguments is None:
parsed_arguments = {}
return json.dumps(make_json_safe(parsed_arguments), sort_keys=True, separators=(",", ":"))
def get_role_value(message: Any) -> str:
"""Extract role string from a message object.
Handles both enum roles (with .value) and string roles.
Args:
message: Message object with role attribute
Returns:
Role as lowercase string, or empty string if not found
"""
role = getattr(message, "role", None)
if role is None:
return ""
if hasattr(role, "value"):
return str(role.value)
return str(role)
def normalize_agui_role(raw_role: Any) -> str:
"""Normalize an AG-UI role to a standard role string.
Args:
raw_role: Raw role value from AG-UI message
Returns:
Normalized role string (user, assistant, system, tool, or reasoning)
"""
if not isinstance(raw_role, str):
return "user"
role = raw_role.lower()
if role == "developer":
return "system"
if role in ALLOWED_AGUI_ROLES:
return role
return "user"
def extract_state_from_tool_args(
args: dict[str, Any] | None,
tool_arg_name: str,
) -> Any:
"""Extract state value from tool arguments based on config.
Args:
args: Parsed tool arguments dict
tool_arg_name: Name of the argument to extract, or "*" for entire args
Returns:
Extracted state value, or None if not found
"""
if not args:
return None
if tool_arg_name == "*":
return args
return args.get(tool_arg_name)
def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Merge state updates.
Args:
current: Current state dictionary
update: Update to apply
Returns:
Merged state
"""
result = copy.deepcopy(current)
result.update(update)
return result
def make_json_safe(obj: Any) -> Any: # noqa: ANN401
"""Make an object JSON serializable.
Args:
obj: Object to make JSON safe
Returns:
JSON-serializable version of the object
"""
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if is_dataclass(obj):
# asdict may return nested non-dataclass objects, so recursively make them safe
return make_json_safe(asdict(obj)) # type: ignore[arg-type]
if hasattr(obj, "model_dump"):
return make_json_safe(obj.model_dump())
if hasattr(obj, "to_dict"):
return make_json_safe(obj.to_dict())
if hasattr(obj, "dict"):
return make_json_safe(obj.dict())
if hasattr(obj, "__dict__"):
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
if isinstance(obj, (list, tuple)):
return [make_json_safe(item) for item in obj] # type: ignore[misc]
if isinstance(obj, dict):
return {key: make_json_safe(value) for key, value in obj.items()} # type: ignore[misc]
return str(obj)
def convert_agui_tools_to_agent_framework(
agui_tools: list[dict[str, Any]] | None,
) -> list[FunctionTool] | None:
"""Convert AG-UI tool definitions to Agent Framework FunctionTool declarations.
Creates declaration-only FunctionTool instances (no executable implementation).
These are used to tell the LLM about available tools. The actual execution
happens on the client side via function invocation mixin.
CRITICAL: These tools MUST have func=None so that declaration_only returns True.
This prevents the server from trying to execute client-side tools.
Args:
agui_tools: List of AG-UI tool definitions with name, description, parameters
Returns:
List of FunctionTool declarations, or None if no tools provided
"""
if not agui_tools:
return None
result: list[FunctionTool] = []
for tool_def in agui_tools:
# Create declaration-only FunctionTool (func=None means no implementation)
# When func=None, the declaration_only property returns True,
# which tells the function invocation mixin to return the function call
# without executing it (so it can be sent back to the client)
func: FunctionTool = FunctionTool(
name=tool_def.get("name", ""),
description=tool_def.get("description", ""),
func=None, # CRITICAL: Makes declaration_only=True
input_model=tool_def.get("parameters", {}),
)
result.append(func)
return result
def convert_tools_to_agui_format(
tools: (
FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[dict[str, Any]] | None:
"""Convert tools to AG-UI format.
This sends only the metadata (name, description, JSON schema) to the server.
The actual executable implementation stays on the client side.
The function invocation mixin handles client-side execution when
the server requests a function.
Args:
tools: Tools to convert (single tool or sequence of tools)
Returns:
List of tool specifications in AG-UI format, or None if no tools provided
"""
if not tools:
return None
# Normalize to list
if not isinstance(tools, list):
tool_list: list[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]] = [tools] # type: ignore[list-item]
else:
tool_list = tools # type: ignore[assignment]
results: list[dict[str, Any]] = []
for tool_item in tool_list:
if isinstance(tool_item, dict):
# Already in dict format, pass through
results.append(tool_item) # type: ignore[arg-type]
elif isinstance(tool_item, FunctionTool):
# Convert FunctionTool to AG-UI tool format
results.append(
{
"name": tool_item.name,
"description": tool_item.description,
"parameters": tool_item.parameters(),
}
)
elif callable(tool_item):
# Convert callable to FunctionTool first, then to AG-UI format
from agent_framework import tool
ai_func = tool(tool_item)
results.append(
{
"name": ai_func.name,
"description": ai_func.description,
"parameters": ai_func.parameters(),
}
)
# Note: dict-based hosted tools (CodeInterpreter, WebSearch, etc.) are passed through
# as-is in the first branch. Non-FunctionTool, non-dict items are skipped.
return results if results else None
def get_conversation_id_from_update(update: AgentResponseUpdate) -> str | None:
"""Extract conversation ID from AgentResponseUpdate metadata.
Args:
update: AgentRunResponseUpdate instance
Returns:
Conversation ID if present, else None
"""
if isinstance(update.raw_representation, ChatResponseUpdate):
return update.raw_representation.conversation_id
return None
@@ -0,0 +1,404 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow wrapper for AG-UI protocol compatibility."""
from __future__ import annotations
import copy
import logging
import uuid
from collections.abc import AsyncGenerator, Callable
from typing import Any, cast
from ag_ui.core import (
BaseEvent,
MessagesSnapshotEvent,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import Workflow
from ._message_adapters import agui_messages_to_snapshot_format
from ._run_common import (
_build_run_finished_event,
_extract_resume_payload,
_normalize_resume_interrupts,
_reconstruct_messages_from_thread_snapshot,
)
from ._snapshots import (
_DEFAULT_STATE_INPUT_KEY,
_SNAPSHOT_SCOPE_INPUT_KEY,
AGUIThreadSnapshot,
AGUIThreadSnapshotStore,
_clear_thread_snapshot_interrupt,
)
from ._utils import generate_event_id, make_json_safe
from ._workflow_run import run_workflow_stream
logger = logging.getLogger(__name__)
WorkflowFactory = Callable[[str], Workflow]
def _cancelled_resume_interrupt_ids(resume_payload: Any) -> set[str]:
"""Return cancelled interrupt ids from a resume payload."""
return {
str(interrupt["id"])
for interrupt in _normalize_resume_interrupts(resume_payload)
if interrupt.get("status") == "cancelled"
}
def _event_messages_to_snapshot_dicts(messages: list[Any]) -> list[dict[str, Any]]:
"""Convert AG-UI message event models to plain snapshot dictionaries."""
safe_messages = make_json_safe(messages)
if not isinstance(safe_messages, list):
return []
return [cast(dict[str, Any], message) for message in safe_messages if isinstance(message, dict)]
class _WorkflowSnapshotBuilder:
"""Capture replayable workflow protocol output without retaining raw events."""
def __init__(self, raw_messages: list[dict[str, Any]]) -> None:
self._synthesized_messages = agui_messages_to_snapshot_format(raw_messages)
self._emitted_messages: list[dict[str, Any]] | None = None
self._open_text_message: dict[str, Any] | None = None
self._tool_call_message: dict[str, Any] | None = None
self._tool_calls_by_id: dict[str, dict[str, Any]] = {}
self.state: dict[str, Any] | None = None
self.interrupt: list[dict[str, Any]] | None = None
def observe(self, event: BaseEvent) -> None:
"""Fold one replayable AG-UI event into the latest snapshot state."""
if isinstance(event, StateSnapshotEvent):
state = make_json_safe(event.snapshot)
if isinstance(state, dict):
self.state = cast(dict[str, Any], state)
return
if isinstance(event, MessagesSnapshotEvent):
self._emitted_messages = _event_messages_to_snapshot_dicts(list(event.messages))
return
if isinstance(event, RunFinishedEvent):
outcome = getattr(event, "outcome", None)
interrupt = (
make_json_safe(getattr(outcome, "interrupts", None))
if getattr(outcome, "type", None) == "interrupt"
else None
)
if isinstance(interrupt, list):
self.interrupt = [cast(dict[str, Any], item) for item in interrupt if isinstance(item, dict)]
return
if self._emitted_messages is not None:
return
if isinstance(event, TextMessageStartEvent):
self._observe_text_start(event)
elif isinstance(event, TextMessageContentEvent):
self._observe_text_content(event)
elif isinstance(event, TextMessageEndEvent):
self._observe_text_end(event)
elif isinstance(event, ToolCallStartEvent):
self._observe_tool_call_start(event)
elif isinstance(event, ToolCallArgsEvent):
self._observe_tool_call_args(event)
elif isinstance(event, ToolCallResultEvent):
self._observe_tool_call_result(event)
def build(self) -> AGUIThreadSnapshot:
"""Return the replayable thread snapshot."""
self._flush_open_text_message()
messages = self._emitted_messages if self._emitted_messages is not None else self._synthesized_messages
return AGUIThreadSnapshot(messages=messages, state=self.state, interrupt=self.interrupt)
def _observe_text_start(self, event: TextMessageStartEvent) -> None:
if self._open_text_message is not None and self._open_text_message.get("id") != event.message_id:
self._flush_open_text_message()
self._open_text_message = {"id": event.message_id, "role": event.role, "content": ""}
def _observe_text_content(self, event: TextMessageContentEvent) -> None:
if self._open_text_message is None or self._open_text_message.get("id") != event.message_id:
self._open_text_message = {"id": event.message_id, "role": "assistant", "content": ""}
self._open_text_message["content"] = f"{self._open_text_message.get('content', '')}{event.delta}"
def _observe_text_end(self, event: TextMessageEndEvent) -> None:
if self._open_text_message is None or self._open_text_message.get("id") != event.message_id:
return
self._flush_open_text_message()
def _observe_tool_call_start(self, event: ToolCallStartEvent) -> None:
parent_message_id = event.parent_message_id
if (
self._open_text_message is not None
and parent_message_id is not None
and self._open_text_message.get("id") == parent_message_id
and self._open_text_message.get("content")
):
self._open_text_message["id"] = generate_event_id()
self._flush_open_text_message()
if self._tool_call_message is None or (
parent_message_id is not None and self._tool_call_message.get("id") != parent_message_id
):
self._tool_call_message = {
"id": parent_message_id or generate_event_id(),
"role": "assistant",
"tool_calls": [],
}
self._synthesized_messages.append(self._tool_call_message)
tool_call = {
"id": event.tool_call_id,
"type": "function",
"function": {"name": event.tool_call_name, "arguments": ""},
}
cast(list[dict[str, Any]], self._tool_call_message["tool_calls"]).append(tool_call)
self._tool_calls_by_id[event.tool_call_id] = tool_call
def _observe_tool_call_args(self, event: ToolCallArgsEvent) -> None:
tool_call = self._tool_calls_by_id.get(event.tool_call_id)
if tool_call is None:
return
function_payload = cast(dict[str, Any], tool_call["function"])
function_payload["arguments"] = f"{function_payload.get('arguments', '')}{event.delta}"
def _observe_tool_call_result(self, event: ToolCallResultEvent) -> None:
self._synthesized_messages.append(
{
"id": event.message_id,
"role": "tool",
"toolCallId": event.tool_call_id,
"content": event.content,
}
)
# A result closes the current tool-call group; later tool calls start a new
# assistant message so replayed transcripts keep results adjacent to their
# tool_calls message, which provider APIs require.
self._tool_call_message = None
def _flush_open_text_message(self) -> None:
if self._open_text_message is None:
return
if self._open_text_message.get("content"):
self._synthesized_messages.append(self._open_text_message)
# Text between tool calls closes the current tool-call group as well.
self._tool_call_message = None
self._open_text_message = None
async def _hydrate_workflow_thread_snapshot(
*,
snapshot_store: AGUIThreadSnapshotStore,
scope: str,
thread_id: str,
run_id: str,
) -> AsyncGenerator[BaseEvent]:
"""Replay the latest stored workflow AG-UI Thread Snapshot without invoking the workflow."""
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
snapshot = await snapshot_store.get(scope=scope, thread_id=thread_id)
if snapshot is None:
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id)
return
if snapshot.state is not None:
yield StateSnapshotEvent(snapshot=snapshot.state)
if snapshot.messages:
yield MessagesSnapshotEvent(messages=snapshot.messages) # type: ignore[arg-type]
yield _build_run_finished_event(run_id=run_id, thread_id=thread_id, interrupts=snapshot.interrupt)
class AgentFrameworkWorkflow:
"""Base AG-UI workflow wrapper.
Can wrap a native ``Workflow`` or be subclassed for custom ``run`` behavior.
"""
def __init__(
self,
workflow: Workflow | None = None,
*,
workflow_factory: WorkflowFactory | None = None,
name: str | None = None,
description: str | None = None,
snapshot_store: AGUIThreadSnapshotStore | None = None,
) -> None:
"""Initialize the AG-UI workflow wrapper.
Args:
workflow: Optional workflow instance to expose.
workflow_factory: Optional factory for thread-scoped workflow instances.
name: Optional workflow name.
description: Optional workflow description.
snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless
endpoint setup also provides an explicit Snapshot Scope resolver.
"""
if workflow is not None and workflow_factory is not None:
raise ValueError("Pass either workflow= or workflow_factory=, not both.")
self.workflow = workflow
self._workflow_factory = workflow_factory
# Cache keyed by (snapshot_scope, thread_id): the Snapshot Scope is the
# authorization boundary, so the same thread id under different scopes
# must never share an in-memory workflow instance.
self._workflow_by_thread: dict[tuple[str | None, str], Workflow] = {}
self.name = name if name is not None else getattr(workflow, "name", "workflow")
self.description = description if description is not None else getattr(workflow, "description", "")
self.snapshot_store = snapshot_store
@staticmethod
def _thread_id_from_input(input_data: dict[str, Any]) -> str:
"""Resolve a stable thread id from AG-UI input payload."""
thread_id = input_data.get("thread_id") or input_data.get("threadId")
if thread_id is not None:
return str(thread_id)
return str(uuid.uuid4())
def _resolve_workflow(self, thread_id: str, snapshot_scope: str | None = None) -> Workflow:
"""Get the workflow instance for the current run."""
if self.workflow is not None:
return self.workflow
if self._workflow_factory is None:
raise NotImplementedError("No workflow is attached. Override run or pass workflow=/workflow_factory=.")
cache_key = (snapshot_scope, thread_id)
workflow = self._workflow_by_thread.get(cache_key)
if workflow is None:
workflow = self._workflow_factory(thread_id)
if not isinstance(workflow, Workflow):
raise TypeError("workflow_factory must return a Workflow instance.")
self._workflow_by_thread[cache_key] = workflow
return workflow
def clear_thread_workflow(self, thread_id: str, snapshot_scope: str | None = None) -> None:
"""Drop cached workflow instances for a thread, optionally limited to one Snapshot Scope."""
if snapshot_scope is not None:
self._workflow_by_thread.pop((snapshot_scope, thread_id), None)
return
for key in [key for key in self._workflow_by_thread if key[1] == thread_id]:
del self._workflow_by_thread[key]
def clear_workflow_cache(self) -> None:
"""Drop all cached thread workflow instances."""
self._workflow_by_thread.clear()
async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]:
"""Run the wrapped workflow and yield AG-UI events.
Subclasses may override this to provide custom AG-UI streams.
"""
thread_id = self._thread_id_from_input(input_data)
run_id = str(input_data.get("run_id") or input_data.get("runId") or uuid.uuid4())
snapshot_scope = cast(str | None, input_data.get(_SNAPSHOT_SCOPE_INPUT_KEY))
raw_messages = list(cast(list[dict[str, Any]], input_data.get("messages", []) or []))
resume_payload = _extract_resume_payload(input_data)
snapshot_store = self.snapshot_store
if snapshot_store is not None and snapshot_scope is not None and not raw_messages and resume_payload is None:
async for event in _hydrate_workflow_thread_snapshot(
snapshot_store=snapshot_store,
scope=snapshot_scope,
thread_id=thread_id,
run_id=run_id,
):
yield event
return
# Load the stored snapshot for follow-up turns so the workflow runs with the
# full persisted thread history instead of just the latest request messages.
stored_snapshot: AGUIThreadSnapshot | None = None
if snapshot_store is not None and snapshot_scope is not None:
stored_snapshot = await snapshot_store.get(scope=snapshot_scope, thread_id=thread_id)
if stored_snapshot is not None and resume_payload is None:
raw_messages = _reconstruct_messages_from_thread_snapshot(
stored_messages=stored_snapshot.messages,
incoming_messages=raw_messages,
stored_interrupt=stored_snapshot.interrupt,
)
input_data["messages"] = raw_messages
# Merge stored state with request overrides, then fill endpoint-deferred
# defaults only for keys missing from both.
request_state = input_data.get("state")
deferred_default_state = cast(dict[str, Any] | None, input_data.get(_DEFAULT_STATE_INPUT_KEY))
effective_state: dict[str, Any] = {}
if stored_snapshot is not None and stored_snapshot.state is not None:
effective_state.update(stored_snapshot.state)
if isinstance(request_state, dict):
effective_state.update(cast(dict[str, Any], request_state))
if deferred_default_state:
for key, value in deferred_default_state.items():
if key not in effective_state:
effective_state[key] = copy.deepcopy(value)
if effective_state:
input_data["state"] = effective_state
workflow = self._resolve_workflow(thread_id, snapshot_scope)
builder_seed_messages = raw_messages
if resume_payload is not None and stored_snapshot is not None:
# Resume requests carry only the synthesized interrupt response, so seed
# the builder with stored history to avoid persisting a truncated thread.
builder_seed_messages = [
copy.deepcopy(message) for message in stored_snapshot.messages
] + builder_seed_messages
snapshot_builder = (
_WorkflowSnapshotBuilder(builder_seed_messages)
if snapshot_store is not None and snapshot_scope is not None
else None
)
if snapshot_builder is not None and effective_state:
# Seed builder state so a run that emits no StateSnapshotEvent still
# persists the latest known Shared State instead of dropping it.
state_snapshot = make_json_safe(effective_state)
if isinstance(state_snapshot, dict):
snapshot_builder.state = cast(dict[str, Any], state_snapshot)
run_error_emitted = False
async for event in run_workflow_stream(input_data, workflow):
if snapshot_builder is not None:
snapshot_builder.observe(event)
if isinstance(event, RunErrorEvent):
run_error_emitted = True
if (
getattr(event, "code", None) == "WORKFLOW_RESUME_CANCELLED"
and snapshot_store is not None
and snapshot_scope is not None
):
await _clear_thread_snapshot_interrupt(
snapshot_store=snapshot_store,
scope=snapshot_scope,
thread_id=thread_id,
interrupt_ids=_cancelled_resume_interrupt_ids(resume_payload),
)
yield event
if (
snapshot_builder is not None
and not run_error_emitted
and snapshot_store is not None
and snapshot_scope is not None
):
try:
await snapshot_store.save(
scope=snapshot_scope,
thread_id=thread_id,
snapshot=snapshot_builder.build(),
)
except Exception:
# RUN_FINISHED has already been yielded; a store failure must not
# surface as a second terminal RUN_ERROR event. The previous
# snapshot stays available for hydration.
logger.exception(
"Failed to save AG-UI Thread Snapshot for scope=%s thread_id=%s; keeping previous snapshot.",
snapshot_scope,
thread_id,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
# Marker file for PEP 561
@@ -0,0 +1,3 @@
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-api-key-here
PORT=8000
@@ -0,0 +1,5 @@
{
"python.analysis.extraPaths": [
"${workspaceFolder}/packages/ag-ui/examples"
]
}
@@ -0,0 +1,360 @@
# Agent Framework AG-UI Integration
AG-UI protocol integration for Agent Framework, enabling seamless integration with AG-UI's web interface and streaming protocol.
## Installation
```bash
pip install agent-framework-ag-ui
```
## Quick Start
### Using Example Agents with Any Chat Client
All example agents are factory functions that accept any `SupportsChatGetResponse`-compatible chat client:
```python
from fastapi import FastAPI
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
app = FastAPI()
# Option 1: Use Azure OpenAI
azure_client = OpenAIChatCompletionClient(model="gpt-4")
add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat")
# Option 2: Use OpenAI
openai_client = OpenAIChatClient(model="gpt-4o")
add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather")
# Run with: uvicorn main:app --reload
```
### Creating Your Own Agent
```python
from fastapi import FastAPI
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = Agent(
name="my_agent",
instructions="You are a helpful assistant.",
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
# Create FastAPI app and add AG-UI endpoint
app = FastAPI()
add_agent_framework_fastapi_endpoint(app, agent, "/agent")
# Run with: uvicorn main:app --reload
```
## Features
This integration supports all 7 AG-UI features:
1. **Agentic Chat**: Basic streaming chat with tool calling support
2. **Backend Tool Rendering**: Tools executed on backend with results streamed via ToolCallResultEvent
3. **Human in the Loop**: Function approval requests for user confirmation before tool execution
4. **Agentic Generative UI**: Async tools for long-running operations with progress updates
5. **Tool-based Generative UI**: Custom UI components rendered on frontend based on tool calls
6. **Shared State**: Bidirectional state sync using StateSnapshotEvent and StateDeltaEvent
7. **Predictive State Updates**: Stream tool arguments as optimistic state updates during execution
## Examples
All example agents are implemented as **factory functions** that accept any chat client implementing `SupportsChatGetResponse`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation.
### Available Example Agents
Complete examples for all AG-UI features are available:
- `simple_agent(client)` - Basic agentic chat (Feature 1)
- `weather_agent(client)` - Backend tool rendering (Feature 2)
- `human_in_the_loop_agent(client)` - Human-in-the-loop with step customization (Feature 3)
- `task_steps_agent_wrapped(client)` - Agentic generative UI with step execution (Feature 4)
- `ui_generator_agent(client)` - Tool-based generative UI (Feature 5)
- `recipe_agent(client)` - Shared state management (Feature 6)
- `document_writer_agent(client)` - Predictive state updates (Feature 7)
- `research_assistant_agent(client)` - Research with progress events
- `task_planner_agent(client)` - Task planning with approvals
- `subgraphs_agent()` - Deterministic travel-planning subgraphs flow (Dojo `subgraphs` feature)
### Using Example Agents
```python
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from agent_framework_ag_ui_examples.agents import (
simple_agent,
weather_agent,
recipe_agent,
)
# Create a chat client (use any SupportsChatGetResponse implementation)
azure_client = OpenAIChatCompletionClient(model="gpt-4")
openai_client = OpenAIChatClient(model="gpt-4o")
# Create agent instances by calling the factory functions
agent1 = simple_agent(azure_client)
agent2 = weather_agent(openai_client)
agent3 = recipe_agent(azure_client)
```
### Running the Example Server
The example server demonstrates all 7 AG-UI features:
```bash
# Install the package
pip install agent-framework-ag-ui
# Run the example server
python -m agent_framework_ag_ui_examples
# Or with debug logging
ENABLE_DEBUG_LOGGING=1 python -m agent_framework_ag_ui_examples
```
The server exposes endpoints at:
- `/agentic_chat` - Simple chat with `simple_agent`
- `/backend_tool_rendering` - Weather tools with `weather_agent`
- `/human_in_the_loop` - Step approval with `human_in_the_loop_agent`
- `/agentic_generative_ui` - Task steps with `task_steps_agent_wrapped`
- `/tool_based_generative_ui` - Custom UI components with `ui_generator_agent`
- `/shared_state` - Recipe management with `recipe_agent`
- `/predictive_state_updates` - Document writing with `document_writer_agent`
- `/subgraphs` - Travel planner with interrupt-driven flight/hotel choices via `subgraphs_agent`
### Interrupt and Resume Shape
Human-in-the-loop and workflow examples use the canonical AG-UI protocol shape. A paused run finishes with
`RUN_FINISHED.outcome.type == "interrupt"` and renders prompts from `RUN_FINISHED.outcome.interrupts`; it does not
depend on a stable top-level `RUN_FINISHED.interrupt` field.
Resume interrupted example threads with a canonical `resume` array:
```json
{
"threadId": "thread-1",
"messages": [],
"resume": [
{
"interruptId": "interrupt_1",
"status": "resolved",
"payload": {
"approved": true
}
}
]
}
```
### Complete FastAPI Example
```python
from fastapi import FastAPI
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import (
simple_agent,
weather_agent,
human_in_the_loop_agent,
task_steps_agent_wrapped,
ui_generator_agent,
recipe_agent,
document_writer_agent,
subgraphs_agent,
)
app = FastAPI(title="AG-UI Examples")
# Create a chat client (shared across all agents, or create individual ones)
client = OpenAIChatCompletionClient(model="gpt-4")
# Add all example endpoints
add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat")
add_agent_framework_fastapi_endpoint(app, weather_agent(client), "/backend_tool_rendering")
add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(client), "/human_in_the_loop")
add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(client), "/agentic_generative_ui") # type: ignore[arg-type]
add_agent_framework_fastapi_endpoint(app, ui_generator_agent(client), "/tool_based_generative_ui")
add_agent_framework_fastapi_endpoint(app, recipe_agent(client), "/shared_state")
add_agent_framework_fastapi_endpoint(app, document_writer_agent(client), "/predictive_state_updates")
add_agent_framework_fastapi_endpoint(app, subgraphs_agent(), "/subgraphs")
```
## Architecture
The package uses a clean, orchestrator-based architecture:
- **AgentFrameworkAgent**: Lightweight wrapper that delegates to orchestrators
- **Orchestrators**: Handle different execution flows (default, human-in-the-loop, etc.)
- **Confirmation Strategies**: Domain-specific confirmation messages (extensible)
- **AgentFrameworkEventBridge**: Converts AgentResponseUpdate to AG-UI events
- **Message Adapters**: Bidirectional conversion between AG-UI and Agent Framework message formats
- **FastAPI Endpoint**: Streaming HTTP endpoint with Server-Sent Events (SSE)
### Key Design Patterns
- **Orchestrator Pattern**: Separates flow control from protocol translation
- **Strategy Pattern**: Pluggable confirmation message strategies
- **Context Object**: Lazy-loaded execution context passed to orchestrators
- **Event Bridge**: Stateless translation of Agent Framework events to AG-UI events
## Advanced Usage
### Creating Custom Agent Factories
You can create your own agent factories following the same pattern as the examples:
```python
from agent_framework import Agent, tool
from agent_framework import SupportsChatGetResponse
from agent_framework.ag_ui import AgentFrameworkAgent
@tool
def my_tool(param: str) -> str:
"""My custom tool."""
return f"Result: {param}"
def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent:
"""Create a custom agent with the specified chat client.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance
"""
agent = Agent(
name="my_custom_agent",
instructions="Custom instructions here",
client=client,
tools=[my_tool],
)
return AgentFrameworkAgent(
agent=agent,
name="MyCustomAgent",
description="My custom agent description",
)
# Use it
from agent_framework.openai import OpenAIChatCompletionClient
client = OpenAIChatCompletionClient()
agent = my_custom_agent(client)
```
### Shared State
State is injected as system messages and updated via predictive state updates:
```python
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = Agent(
name="recipe_agent",
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
state_schema = {
"recipe": {
"type": "object",
"properties": {
"name": {"type": "string"},
"ingredients": {"type": "array"}
}
}
}
# Configure which tool updates which state fields
predict_state_config = {
"recipe": {"tool": "update_recipe", "tool_argument": "recipe_data"}
}
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema=state_schema,
predict_state_config=predict_state_config,
)
```
### Predictive State Updates
Predictive state updates automatically stream tool arguments as optimistic state updates:
```python
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = Agent(
name="document_writer",
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
predict_state_config = {
"current_title": {"tool": "write_document", "tool_argument": "title"},
"current_content": {"tool": "write_document", "tool_argument": "content"},
}
wrapped_agent = AgentFrameworkAgent(
agent=agent,
state_schema={"current_title": {"type": "string"}, "current_content": {"type": "string"}},
predict_state_config=predict_state_config,
require_confirmation=True, # User can approve/reject changes
)
```
### Human in the Loop
Human-in-the-loop is automatically handled when tools are marked for approval:
```python
from agent_framework import tool
@tool(approval_mode="always_require")
def sensitive_action(param: str) -> str:
"""This action requires user approval."""
return f"Executed with {param}"
# The orchestrator automatically detects approval responses and handles them
```
### Custom Orchestrators
Add custom execution flows by implementing the Orchestrator pattern:
```python
from agent_framework.ag_ui._orchestrators import Orchestrator, ExecutionContext
class MyCustomOrchestrator(Orchestrator):
def can_handle(self, context: ExecutionContext) -> bool:
# Return True if this orchestrator should handle the request
return context.input_data.get("custom_mode") == True
async def run(self, context: ExecutionContext):
# Custom execution logic
yield RunStartedEvent(...)
# ... your custom flow
yield RunFinishedEvent(...)
wrapped_agent = AgentFrameworkAgent(
agent=your_agent,
orchestrators=[MyCustomOrchestrator(), DefaultOrchestrator()],
)
## License
MIT
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agents for AG-UI demonstration."""
from . import agents
__all__ = ["agents"]
@@ -0,0 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
"""Entry point for running the AG-UI examples server as a module."""
from .server.main import main
if __name__ == "__main__":
main()
@@ -0,0 +1,27 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agents for AG-UI demonstration."""
from .document_writer_agent import document_writer_agent
from .human_in_the_loop_agent import human_in_the_loop_agent
from .recipe_agent import recipe_agent
from .research_assistant_agent import research_assistant_agent
from .simple_agent import simple_agent
from .subgraphs_agent import subgraphs_agent
from .task_planner_agent import task_planner_agent
from .task_steps_agent import task_steps_agent_wrapped
from .ui_generator_agent import ui_generator_agent
from .weather_agent import weather_agent
__all__ = [
"document_writer_agent",
"human_in_the_loop_agent",
"recipe_agent",
"research_assistant_agent",
"simple_agent",
"subgraphs_agent",
"task_planner_agent",
"task_steps_agent_wrapped",
"ui_generator_agent",
"weather_agent",
]
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating predictive state updates with document writing."""
from __future__ import annotations
from agent_framework import Agent, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
@tool(approval_mode="always_require")
def write_document(document: str) -> str:
"""Write a document. Use markdown formatting to format the document.
It's good to format the document extensively so it's easy to read.
You can use all kinds of markdown.
However, do not use italic or strike-through formatting, it's reserved for another purpose.
You MUST write the full document, even when changing only a few words.
When making edits to the document, try to make them minimal - do not change every word.
Keep stories SHORT!
Args:
document: The complete document content in markdown format
Returns:
Confirmation that the document was written
"""
return "Document written."
_DOCUMENT_WRITER_INSTRUCTIONS = (
"You are a helpful assistant for writing documents. "
"To write the document, you MUST use the write_document tool. "
"You MUST write the full document, even when changing only a few words. "
"When you wrote the document, DO NOT repeat it as a message. "
"Just briefly summarize the changes you made. 2 sentences max. "
"\n\n"
"The current state of the document will be provided to you. "
"When editing, make minimal changes - do not change every word unless requested."
)
def document_writer_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent:
"""Create a document writer agent with predictive state updates.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with document writing capabilities
"""
agent = Agent(
name="document_writer",
instructions=_DOCUMENT_WRITER_INSTRUCTIONS,
client=client,
tools=[write_document],
)
return AgentFrameworkAgent(
agent=agent,
name="DocumentWriter",
description="Writes and edits documents with predictive state updates",
state_schema={
"document": {"type": "string", "description": "The current document content"},
},
predict_state_config={
"document": {"tool": "write_document", "tool_argument": "document"},
},
)
@@ -0,0 +1,86 @@
# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-loop agent demonstrating step customization (Feature 5)."""
from enum import Enum
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse, tool
from pydantic import BaseModel, Field
class StepStatus(str, Enum):
"""Status of a task step."""
ENABLED = "enabled"
DISABLED = "disabled"
class TaskStep(BaseModel):
"""A single step in a task execution plan."""
description: str = Field(..., description="The text of the step in imperative form (e.g., 'Dig hole', 'Open door')")
status: StepStatus = Field(default=StepStatus.ENABLED, description="Whether the step is enabled or disabled")
@tool(
name="generate_task_steps",
description="Generate execution steps for a task",
approval_mode="always_require",
)
def generate_task_steps(steps: list[TaskStep]) -> str:
"""Make up 10 steps (only a couple of words per step) that are required for a task.
The step should be in imperative form (i.e. Dig hole, Open door, ...).
Each step will have status='enabled' by default.
Args:
steps: An array of 10 step objects, each containing description and status
Returns:
Confirmation message
"""
return f"Generated {len(steps)} execution steps for the task."
def human_in_the_loop_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]:
"""Create a human-in-the-loop agent using tool-based approach for predictive state.
Args:
client: The chat client to use for the agent
Returns:
A configured Agent instance with human-in-the-loop capabilities
"""
return Agent(
name="human_in_the_loop_agent",
instructions="""You are a helpful assistant that can perform any task by breaking it down into steps.
When asked to perform a task, you MUST call the `generate_task_steps` function with the proper
number of steps per the request.
Rules for steps:
- Each step description should be in imperative form (e.g., "Dig hole", "Open door", "Prepare ingredients")
- Each step should be brief (only a couple of words)
- All steps must have status='enabled' initially
Example steps for "Build a robot":
1. "Design blueprint"
2. "Gather components"
3. "Assemble frame"
4. "Install motors"
5. "Wire electronics"
6. "Program controller"
7. "Test movements"
8. "Add sensors"
9. "Calibrate systems"
10. "Final testing"
IMPORTANT: When you call generate_task_steps, the user will be shown the steps and asked to approve.
Do NOT output any text along with the function call - just call the function.
After the user approves and the function executes, THEN provide a brief acknowledgment like:
"The plan has been created with X steps selected."
""",
client=client,
tools=[generate_task_steps],
)
@@ -0,0 +1,134 @@
# Copyright (c) Microsoft. All rights reserved.
"""Recipe agent example demonstrating shared state management (Feature 3)."""
from __future__ import annotations
from enum import Enum
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
from pydantic import BaseModel, Field
class SkillLevel(str, Enum):
"""The skill level required for the recipe."""
BEGINNER = "Beginner"
INTERMEDIATE = "Intermediate"
ADVANCED = "Advanced"
class CookingTime(str, Enum):
"""The cooking time of the recipe."""
FIVE_MIN = "5 min"
FIFTEEN_MIN = "15 min"
THIRTY_MIN = "30 min"
FORTY_FIVE_MIN = "45 min"
SIXTY_PLUS_MIN = "60+ min"
class Ingredient(BaseModel):
"""An ingredient with its details."""
icon: str = Field(..., description="Emoji icon representing the ingredient (e.g., 🥕)")
name: str = Field(..., description="Name of the ingredient")
amount: str = Field(..., description="Amount or quantity of the ingredient")
class Recipe(BaseModel):
"""A complete recipe."""
title: str = Field(..., description="The title of the recipe")
skill_level: SkillLevel = Field(..., description="The skill level required")
special_preferences: list[str] = Field(
default_factory=list, description="Dietary preferences (e.g., Vegetarian, Gluten-free)"
)
cooking_time: CookingTime = Field(..., description="The estimated cooking time")
ingredients: list[Ingredient] = Field(..., description="Complete list of ingredients")
instructions: list[str] = Field(..., description="Step-by-step cooking instructions")
@tool
def update_recipe(recipe: Recipe) -> str:
"""Update the recipe with new or modified content.
You MUST write the complete recipe with ALL fields, even when changing only a few items.
When modifying an existing recipe, include ALL existing ingredients and instructions plus your changes.
NEVER delete existing data - only add or modify.
Args:
recipe: The complete recipe object with all details
Returns:
Confirmation that the recipe was updated
"""
return "Recipe updated."
_RECIPE_INSTRUCTIONS = """You are a helpful recipe assistant that creates and modifies recipes.
CRITICAL RULES:
1. You will receive the current recipe state in the system context
2. To update the recipe, you MUST use the update_recipe tool
3. When modifying a recipe, ALWAYS include ALL existing data plus your changes in the tool call
4. NEVER delete existing ingredients or instructions - only add or modify
5. After calling the tool, provide a brief conversational message (1-2 sentences)
When creating a NEW recipe:
- Provide all required fields: title, skill_level, cooking_time, ingredients, instructions
- Use actual emojis for ingredient icons (🥕 🧄 🧅 🍅 🌿 🍗 🥩 🧀)
- Leave special_preferences empty unless specified
- Message: "Here's your recipe!" or similar
When MODIFYING or IMPROVING an existing recipe:
- Include ALL existing ingredients + any new ones
- Include ALL existing instructions + any new/modified ones
- Update other fields as needed
- Message: Explain what you improved (e.g., "I upgraded the ingredients to premium quality")
- When asked to "improve", enhance with:
* Better ingredients (upgrade quality, add complementary flavors)
* More detailed instructions
* Professional techniques
* Adjust skill_level if complexity changes
* Add relevant special_preferences
Example improvements:
- Upgrade "chicken""organic free-range chicken breast"
- Add herbs: basil, oregano, thyme
- Add aromatics: garlic, shallots
- Add finishing touches: lemon zest, fresh parsley
- Make instructions more detailed and professional
"""
def recipe_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create a recipe agent with streaming state updates.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with recipe management
"""
agent = Agent(
name="recipe_agent",
instructions=_RECIPE_INSTRUCTIONS,
client=client,
tools=[update_recipe],
)
return AgentFrameworkAgent(
agent=agent,
name="RecipeAgent",
description="Creates and modifies recipes with streaming state updates",
state_schema={
"recipe": {"type": "object", "description": "The current recipe"},
},
predict_state_config={
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
},
require_confirmation=False,
)
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating agentic generative UI with custom events during execution."""
import asyncio
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
@tool
async def research_topic(topic: str) -> str:
"""Research a topic and generate a comprehensive report.
Args:
topic: The topic to research
Returns:
Research report
"""
# Simulate multi-step research process
steps = [
("Searching databases", 1.0),
("Analyzing sources", 1.5),
("Synthesizing information", 1.0),
("Generating report", 0.5),
]
results: list[str] = []
for step_name, duration in steps:
await asyncio.sleep(duration)
results.append(f"- {step_name}: completed")
return f"Research report on '{topic}':\n" + "\n".join(results)
@tool
async def create_presentation(title: str, num_slides: int) -> str:
"""Create a presentation with multiple slides.
Args:
title: Presentation title
num_slides: Number of slides to create
Returns:
Presentation summary
"""
# Simulate slide generation
slides: list[str] = []
for i in range(num_slides):
await asyncio.sleep(0.5)
slides.append(f"Slide {i + 1}: Content for {title}")
return f"Created presentation '{title}' with {num_slides} slides:\n" + "\n".join(slides)
@tool
async def analyze_data(dataset: str) -> str:
"""Analyze a dataset and produce insights.
Args:
dataset: The dataset name to analyze
Returns:
Analysis results
"""
# Simulate data analysis phases
phases = [
("Loading data", 0.8),
("Cleaning data", 1.0),
("Running statistical analysis", 1.2),
("Generating visualizations", 0.7),
]
insights: list[str] = []
for phase_name, duration in phases:
await asyncio.sleep(duration)
insights.append(f"- {phase_name}: done")
return f"Analysis of '{dataset}':\n" + "\n".join(insights)
_RESEARCH_ASSISTANT_INSTRUCTIONS = (
"You are a research and analysis assistant. "
"You can research topics, create presentations, and analyze data. "
"Use the available tools to help users with their research needs."
)
def research_assistant_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create a research assistant agent.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with research capabilities
"""
agent = Agent(
name="research_assistant",
instructions=_RESEARCH_ASSISTANT_INSTRUCTIONS,
client=client,
tools=[research_topic, create_presentation, analyze_data],
)
return AgentFrameworkAgent(
agent=agent,
name="ResearchAssistant",
description="Research assistant that emits progress events during task execution",
)
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse
def simple_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]:
"""Create a simple chat agent.
Args:
client: The chat client to use for the agent
Returns:
A configured Agent instance
"""
return Agent[Any](
name="simple_chat_agent",
instructions="You are a helpful assistant. Be concise and friendly.",
client=client,
)
@@ -0,0 +1,405 @@
# Copyright (c) Microsoft. All rights reserved.
"""Subgraphs travel planner built with MAF workflow primitives."""
import json
import uuid
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from ag_ui.core import (
BaseEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from agent_framework import (
Executor,
Message,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework_ag_ui import AgentFrameworkWorkflow
STATIC_FLIGHTS: list[dict[str, str]] = [
{
"airline": "KLM",
"departure": "Amsterdam (AMS)",
"arrival": "San Francisco (SFO)",
"price": "$650",
"duration": "11h 30m",
},
{
"airline": "United",
"departure": "Amsterdam (AMS)",
"arrival": "San Francisco (SFO)",
"price": "$720",
"duration": "12h 15m",
},
]
STATIC_HOTELS: list[dict[str, str]] = [
{
"name": "Hotel Zephyr",
"location": "Fisherman's Wharf",
"price_per_night": "$280/night",
"rating": "4.2 stars",
},
{
"name": "The Ritz-Carlton",
"location": "Nob Hill",
"price_per_night": "$550/night",
"rating": "4.8 stars",
},
{
"name": "Hotel Zoe",
"location": "Union Square",
"price_per_night": "$320/night",
"rating": "4.4 stars",
},
]
STATIC_EXPERIENCES: list[dict[str, str]] = [
{
"name": "Pier 39",
"type": "activity",
"description": "Iconic waterfront destination with shops and sea lions",
"location": "Fisherman's Wharf",
},
{
"name": "Golden Gate Bridge",
"type": "activity",
"description": "World-famous suspension bridge with stunning views",
"location": "Golden Gate",
},
{
"name": "Swan Oyster Depot",
"type": "restaurant",
"description": "Historic seafood counter serving fresh oysters",
"location": "Polk Street",
},
{
"name": "Tartine Bakery",
"type": "restaurant",
"description": "Artisanal bakery famous for bread and pastries",
"location": "Mission District",
},
]
_STATE_KEY = "subgraphs_state"
@dataclass
class _PresentFlights:
pass
@dataclass
class _PresentHotels:
pass
@dataclass
class _PlanExperiences:
pass
@dataclass
class _FinalizeTrip:
pass
def _initial_state() -> dict[str, Any]:
return {
"itinerary": {},
"experiences": [],
"flights": [],
"hotels": [],
"planning_step": "start",
"active_agent": "supervisor",
}
def _emit_text_events(text: str) -> list[BaseEvent]:
message_id = str(uuid.uuid4())
return [
TextMessageStartEvent(message_id=message_id, role="assistant"),
TextMessageContentEvent(message_id=message_id, delta=text),
TextMessageEndEvent(message_id=message_id),
]
async def _emit_text(ctx: WorkflowContext[Any, BaseEvent], text: str) -> None:
for event in _emit_text_events(text):
await ctx.yield_output(event)
async def _emit_state_snapshot(ctx: WorkflowContext[Any, BaseEvent], state: dict[str, Any]) -> None:
await ctx.yield_output(StateSnapshotEvent(snapshot=deepcopy(state)))
def _flight_interrupt_value() -> dict[str, Any]:
return {
"message": "Choose the flight you want. I recommend KLM because it is cheaper and usually on time.",
"options": deepcopy(STATIC_FLIGHTS),
"recommendation": deepcopy(STATIC_FLIGHTS[0]),
"agent": "flights",
}
def _hotel_interrupt_value() -> dict[str, Any]:
return {
"message": "Choose your hotel. I recommend Hotel Zoe for the best value in a central location.",
"options": deepcopy(STATIC_HOTELS),
"recommendation": deepcopy(STATIC_HOTELS[2]),
"agent": "hotels",
}
def _normalize_flight(value: Any) -> dict[str, str] | None:
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if isinstance(value, dict) and value.get("airline"):
return {
"airline": str(value.get("airline", "")),
"departure": str(value.get("departure", "")),
"arrival": str(value.get("arrival", "")),
"price": str(value.get("price", "")),
"duration": str(value.get("duration", "")),
}
return None
def _normalize_hotel(value: Any) -> dict[str, str] | None:
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if isinstance(value, dict) and value.get("name"):
return {
"name": str(value.get("name", "")),
"location": str(value.get("location", "")),
"price_per_night": str(value.get("price_per_night", "")),
"rating": str(value.get("rating", "")),
}
return None
def _load_state(ctx: WorkflowContext[Any, BaseEvent]) -> dict[str, Any]:
state = ctx.get_state(_STATE_KEY)
if isinstance(state, dict):
return state
new_state = _initial_state()
ctx.set_state(_STATE_KEY, new_state)
return new_state
class _SupervisorExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="supervisor_agent")
@handler
async def start(self, message: list[Message], ctx: WorkflowContext[_PresentFlights, BaseEvent]) -> None:
del message
state = _initial_state()
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(
ctx,
"Supervisor: I will coordinate our specialist agents to plan your San Francisco trip end to end.",
)
state["active_agent"] = "flights"
state["planning_step"] = "collecting_flights"
state["flights"] = deepcopy(STATIC_FLIGHTS)
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await ctx.send_message(_PresentFlights(), target_id="flights_agent")
@handler
async def finalize(self, message: _FinalizeTrip, ctx: WorkflowContext[Any, BaseEvent]) -> None:
del message
state = _load_state(ctx)
state["active_agent"] = "supervisor"
state["planning_step"] = "complete"
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(ctx, "Supervisor: Your travel planning is complete and your itinerary is ready.")
class _FlightsExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="flights_agent")
@handler
async def present_options(self, message: _PresentFlights, ctx: WorkflowContext[_PresentHotels, BaseEvent]) -> None:
del message
await _emit_text(
ctx,
"Flights Agent: I found two flight options from Amsterdam to San Francisco. "
"KLM is recommended for the best value and schedule.",
)
await ctx.request_info(_flight_interrupt_value(), dict, request_id="flights-choice")
@response_handler
async def handle_selection(
self,
original_request: dict,
response: dict,
ctx: WorkflowContext[_PresentHotels, BaseEvent],
) -> None:
del original_request
state = _load_state(ctx)
selected_flight = _normalize_flight(response)
if selected_flight is None:
state["active_agent"] = "flights"
state["planning_step"] = "collecting_flights"
state["flights"] = deepcopy(STATIC_FLIGHTS)
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(ctx, "Flights Agent: Please choose a flight option from the selection card to continue.")
await ctx.request_info(_flight_interrupt_value(), dict, request_id="flights-choice")
return
itinerary = state.setdefault("itinerary", {})
itinerary["flight"] = selected_flight
state["active_agent"] = "flights"
state["planning_step"] = "booking_flight"
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(
ctx,
f"Flights Agent: Great choice. I will book the {selected_flight['airline']} flight. "
"Now I am routing you to Hotels Agent for accommodation.",
)
state["active_agent"] = "hotels"
state["planning_step"] = "collecting_hotels"
state["hotels"] = deepcopy(STATIC_HOTELS)
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await ctx.send_message(_PresentHotels(), target_id="hotels_agent")
class _HotelsExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="hotels_agent")
@handler
async def present_options(self, message: _PresentHotels, ctx: WorkflowContext[_PlanExperiences, BaseEvent]) -> None:
del message
await _emit_text(
ctx,
"Hotels Agent: I found three accommodation options in San Francisco. "
"Hotel Zoe is recommended for the best balance of location, quality, and price.",
)
await ctx.request_info(_hotel_interrupt_value(), dict, request_id="hotels-choice")
@response_handler
async def handle_selection(
self,
original_request: dict,
response: dict,
ctx: WorkflowContext[_PlanExperiences, BaseEvent],
) -> None:
del original_request
state = _load_state(ctx)
selected_hotel = _normalize_hotel(response)
if selected_hotel is None:
state["active_agent"] = "hotels"
state["planning_step"] = "collecting_hotels"
state["hotels"] = deepcopy(STATIC_HOTELS)
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(ctx, "Hotels Agent: Please choose a hotel option from the selection card to continue.")
await ctx.request_info(_hotel_interrupt_value(), dict, request_id="hotels-choice")
return
itinerary = state.setdefault("itinerary", {})
itinerary["hotel"] = selected_hotel
state["active_agent"] = "hotels"
state["planning_step"] = "booking_hotel"
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await _emit_text(
ctx,
f"Hotels Agent: Excellent, {selected_hotel['name']} is booked. "
"I am routing you to Experiences Agent for activities and restaurants.",
)
state["active_agent"] = "experiences"
state["planning_step"] = "curating_experiences"
state["experiences"] = deepcopy(STATIC_EXPERIENCES)
ctx.set_state(_STATE_KEY, state)
await _emit_state_snapshot(ctx, state)
await ctx.send_message(_PlanExperiences(), target_id="experiences_agent")
class _ExperiencesExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="experiences_agent")
@handler
async def plan(self, message: _PlanExperiences, ctx: WorkflowContext[_FinalizeTrip, BaseEvent]) -> None:
del message
await _emit_text(
ctx,
"Experiences Agent: I planned activities and restaurants including "
"Pier 39, Golden Gate Bridge, Swan Oyster Depot, and Tartine Bakery.",
)
await ctx.send_message(_FinalizeTrip(), target_id="supervisor_agent")
def _build_subgraphs_workflow() -> Workflow:
supervisor = _SupervisorExecutor()
flights = _FlightsExecutor()
hotels = _HotelsExecutor()
experiences = _ExperiencesExecutor()
return (
WorkflowBuilder(
name="subgraphs",
description="Travel planning supervisor with flights/hotels/experiences subgraphs.",
start_executor=supervisor,
)
.add_edge(supervisor, flights)
.add_edge(flights, hotels)
.add_edge(hotels, experiences)
.add_edge(experiences, supervisor)
.build()
)
def _build_subgraphs_workflow_for_thread(thread_id: str) -> Workflow:
"""Create a workflow instance scoped to a single AG-UI thread."""
del thread_id
return _build_subgraphs_workflow()
def subgraphs_agent() -> AgentFrameworkWorkflow:
"""Create the subgraphs travel planner agent."""
return AgentFrameworkWorkflow(
workflow_factory=_build_subgraphs_workflow_for_thread,
name="subgraphs",
description="Travel planning workflow with interrupt-driven selections.",
)
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating human-in-the-loop with function approvals."""
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
@tool(approval_mode="always_require")
def create_calendar_event(title: str, date: str, time: str) -> str:
"""Create a calendar event.
Args:
title: The event title
date: The event date (YYYY-MM-DD)
time: The event time (HH:MM)
Returns:
Confirmation message
"""
return f"Calendar event '{title}' created for {date} at {time}"
@tool(approval_mode="always_require")
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email.
Args:
to: Recipient email address
subject: Email subject
body: Email body text
Returns:
Confirmation message
"""
return f"Email sent to {to} with subject '{subject}'"
@tool(approval_mode="always_require")
def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str) -> str:
"""Book a meeting room.
Args:
room_name: The meeting room name
date: The booking date (YYYY-MM-DD)
start_time: Start time (HH:MM)
end_time: End time (HH:MM)
Returns:
Confirmation message
"""
return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}"
_TASK_PLANNER_INSTRUCTIONS = (
"You are a helpful assistant that plans and executes tasks. "
"You have access to calendar, email, and meeting room booking functions. "
"All of these actions require user approval before execution."
)
def task_planner_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create a task planner agent with user approval for actions.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with task planning capabilities
"""
agent = Agent(
name="task_planner",
instructions=_TASK_PLANNER_INSTRUCTIONS,
client=client,
tools=[create_calendar_event, send_email, book_meeting_room],
)
return AgentFrameworkAgent(
agent=agent,
name="TaskPlanner",
description="Plans and executes tasks with user approval",
)
@@ -0,0 +1,338 @@
# Copyright (c) Microsoft. All rights reserved.
"""Task steps agent demonstrating agentic generative UI (Feature 6)."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from enum import Enum
from typing import Any
from ag_ui.core import (
EventType,
MessagesSnapshotEvent,
RunFinishedEvent,
StateDeltaEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallStartEvent,
)
from agent_framework import Agent, Content, Message, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
from pydantic import BaseModel, Field
from agent_framework_ag_ui import AgentFrameworkWorkflow
class StepStatus(str, Enum):
"""Status of a task step."""
PENDING = "pending"
COMPLETED = "completed"
class TaskStep(BaseModel):
"""A single step in a task."""
description: str = Field(
..., description="The text of the step in gerund form (e.g., 'Digging hole', 'Opening door')"
)
status: StepStatus = Field(default=StepStatus.PENDING, description="The status of the step")
@tool
def generate_task_steps(steps: list[TaskStep]) -> str:
"""Generate a list of task steps for completing a task.
Args:
steps: Complete list of task steps with descriptions and status
Returns:
Confirmation that steps were generated
"""
return "Steps generated."
def _create_task_steps_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create the task steps agent using tool-based approach for streaming.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance
"""
agent = Agent[Any](
name="task_steps_agent",
instructions="""You are a helpful assistant that breaks down tasks into actionable steps.
When asked to perform a task, you MUST:
1. Use the generate_task_steps tool to create the steps
2. Pay attention to how many steps the user requests (if specified)
3. If no specific number is mentioned, use a reasonable number of steps (typically 5-10)
4. Each step description should be in gerund form (e.g., "Designing spacecraft", "Training astronauts")
5. Each step should be brief (only 2-4 words)
6. All steps must have status='pending'
7. After calling the tool, provide a brief conversational message (one sentence) saying you created the plan
Example steps for "Build a treehouse in 5 steps":
- "Selecting location"
- "Gathering materials"
- "Assembling frame"
- "Installing platform"
- "Adding finishing touches"
""",
client=client,
tools=[generate_task_steps],
)
return AgentFrameworkAgent(
agent=agent,
name="TaskStepsAgent",
description="Generates task steps with streaming state updates",
state_schema={
"steps": {"type": "array", "description": "The list of task steps"},
},
predict_state_config={
"steps": {
"tool": "generate_task_steps",
"tool_argument": "steps",
}
},
require_confirmation=False, # Agentic generative UI updates automatically without confirmation
)
# Wrap the agent's run method to add step execution simulation
class TaskStepsAgentWithExecution(AgentFrameworkWorkflow):
"""Wrapper that adds step execution simulation after plan generation.
This wrapper delegates to AgentFrameworkAgent but is recognized as compatible
by add_agent_framework_fastapi_endpoint since it implements run().
"""
def __init__(self, base_agent: AgentFrameworkAgent):
"""Initialize wrapper with base agent."""
super().__init__(name=base_agent.name, description=base_agent.description)
self._base_agent = base_agent
def __getattr__(self, name: str) -> Any:
"""Delegate all other attribute access to base agent."""
return getattr(self._base_agent, name)
async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[Any]:
"""Run the agent and then simulate step execution."""
import logging
import uuid
logger = logging.getLogger(__name__)
logger.info("TaskStepsAgentWithExecution.run() called - wrapper is active")
# First, run the base agent to generate the plan - buffer text messages
final_state: dict[str, Any] = {}
run_finished_event: Any = None
tool_call_id: str | None = None
buffered_text_events: list[Any] = [] # Buffer text from first LLM call
async for event in self._base_agent.run(input_data):
event_type_str = str(event.type) if hasattr(event, "type") else type(event).__name__
logger.info(f"Processing event: {event_type_str}")
match event:
case StateSnapshotEvent(snapshot=snapshot):
final_state = snapshot.copy() if snapshot else {}
logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}")
yield event
case StateDeltaEvent(delta=delta):
# Apply state delta to final_state
if delta:
for patch in delta:
if patch.get("op") == "replace" and patch.get("path") == "/steps":
final_state["steps"] = patch.get("value", [])
logger.info(
f"Applied STATE_DELTA: updated steps to {len(final_state.get('steps', []))} items"
)
logger.info(f"Yielding event immediately: {event_type_str}")
yield event
case RunFinishedEvent():
run_finished_event = event
logger.info("Captured RUN_FINISHED event - will send after step execution and summary")
case ToolCallStartEvent(tool_call_id=call_id):
tool_call_id = call_id
logger.info(f"Captured tool_call_id: {tool_call_id}")
yield event
case TextMessageStartEvent() | TextMessageContentEvent() | TextMessageEndEvent():
buffered_text_events.append(event)
logger.info(f"Buffered {event_type_str} from first LLM call")
case _:
logger.info(f"Yielding event immediately: {event_type_str}")
yield event
logger.info(f"Base agent completed. Final state: {final_state}")
# Now simulate executing the steps
if final_state and "steps" in final_state:
steps = final_state["steps"]
logger.info(f"Starting step execution simulation for {len(steps)} steps")
for i in range(len(steps)):
logger.info(f"Simulating execution of step {i + 1}/{len(steps)}: {steps[i].get('description')}")
await asyncio.sleep(1.0) # Simulate work
# Update step to completed
steps[i]["status"] = "completed"
logger.info(f"Step {i + 1} marked as completed")
# Send delta event with manual JSON patch format
delta_event = StateDeltaEvent(
type=EventType.STATE_DELTA,
delta=[
{
"op": "replace",
"path": f"/steps/{i}/status",
"value": "completed",
}
],
)
logger.info(f"Yielding StateDeltaEvent for step {i + 1}")
yield delta_event
# Send final snapshot
final_snapshot = StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot={"steps": steps},
)
logger.info("Yielding final StateSnapshotEvent with all steps completed")
yield final_snapshot
# SECOND LLM call: Stream summary from chat client directly
logger.info("Making SECOND LLM call to generate summary after step execution")
# Get the underlying chat agent and client
chat_agent = self._base_agent.agent
client = chat_agent.client # type: ignore
# Build messages for summary call
original_messages = input_data.get("messages", [])
# Convert to Message objects if needed
messages: list[Message] = []
for msg in original_messages:
if isinstance(msg, dict):
content_str = msg.get("content", "")
if isinstance(content_str, str):
messages.append(
Message(
role=msg.get("role", "user"),
contents=[Content.from_text(text=content_str)],
)
)
elif isinstance(msg, Message):
messages.append(msg)
# Add completion message
messages.append(
Message(
role="user",
contents=[
Content.from_text(
text="The steps have been successfully executed. Provide a brief one-sentence summary."
)
],
)
)
# Stream the LLM response and manually emit text events
logger.info("Calling chat client for summary")
message_id = str(uuid.uuid4())
try:
# Emit TEXT_MESSAGE_START
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=message_id,
role="assistant",
)
# Small delay to ensure START event is processed before CONTENT events
await asyncio.sleep(0.01)
# Stream completion
accumulated_text = ""
async for chunk in client.get_response(messages=messages, stream=True):
# chunk is ChatResponseUpdate
if hasattr(chunk, "text") and chunk.text:
accumulated_text += chunk.text
# Emit TEXT_MESSAGE_CONTENT
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=chunk.text,
)
# Emit TEXT_MESSAGE_END
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=message_id,
)
logger.info(f"Summary complete: {accumulated_text}")
# Build complete message for persistence
summary_message = {
"role": "assistant",
"content": accumulated_text,
"id": message_id,
}
final_messages = list(original_messages)
final_messages.append(summary_message)
# Emit MessagesSnapshotEvent to persist in history
yield MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT,
messages=final_messages,
)
except Exception as e:
logger.error(f"Error generating summary: {e}")
# Generate a new message ID for the error
error_message_id = str(uuid.uuid4())
# Yield TEXT_MESSAGE_START for error
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=error_message_id,
role="assistant",
)
# Yield error message content
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=error_message_id,
delta=f"[Summary generation error: {e!s}]",
)
# Yield TEXT_MESSAGE_END for error
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=error_message_id,
)
else:
logger.warning(f"No steps found in final_state to execute. final_state={final_state}")
# Finally send the original RUN_FINISHED event
if run_finished_event:
logger.info("Yielding original RUN_FINISHED event")
yield run_finished_event
def task_steps_agent_wrapped(client: SupportsChatGetResponse[Any]) -> TaskStepsAgentWithExecution:
"""Create a task steps agent with execution simulation.
Args:
client: The chat client to use for the agent
Returns:
A wrapped agent instance with step execution simulation
"""
base_agent = _create_task_steps_agent(client)
return TaskStepsAgentWithExecution(base_agent)
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example agent demonstrating Tool-based Generative UI (Feature 5)."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, TypedDict
from agent_framework import Agent, FunctionTool, SupportsChatGetResponse
from agent_framework.ag_ui import AgentFrameworkAgent
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
if TYPE_CHECKING:
from agent_framework import ChatOptions
# Declaration-only tools (func=None) - actual rendering happens on the client side
generate_haiku = FunctionTool(
name="generate_haiku",
description="""Generate a haiku with image and gradient background (FRONTEND_RENDER).
This tool generates UI for displaying a haiku with an image and gradient background.
The frontend should render this as a custom haiku component.""",
func=None, # Makes declaration_only=True so client renders the UI
input_model={
"type": "object",
"properties": {
"english": {
"type": "array",
"items": {"type": "string"},
"description": "English haiku lines (exactly 3 lines)",
"minItems": 3,
"maxItems": 3,
},
"japanese": {
"type": "array",
"items": {"type": "string"},
"description": "Japanese haiku lines (exactly 3 lines)",
"minItems": 3,
"maxItems": 3,
},
"image_name": {
"type": "string",
"description": """Image filename for visual accompaniment. Must be one of:
- "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg"
- "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg"
- "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg"
- "Takachiho_Gorge_Waterfall_River_Lush_Greenery_Japan.jpg"
- "Bonsai_Tree_Potted_Japanese_Art_Green_Foliage.jpeg"
- "Shirakawa-go_Gassho-zukuri_Thatched_Roof_Village_Aerial_View.jpg"
- "Ginkaku-ji_Silver_Pavilion_Kyoto_Japanese_Garden_Pond_Reflection.jpg"
- "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg"
- "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg"
- "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg"
""",
},
"gradient": {
"type": "string",
"description": 'CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")',
},
},
"required": ["english", "japanese", "image_name", "gradient"],
},
)
create_chart = FunctionTool(
name="create_chart",
description="""Create an interactive chart (FRONTEND_RENDER).
This tool creates chart specifications for frontend rendering.
The frontend should render this as an interactive chart component.""",
func=None, # Makes declaration_only=True so client renders the UI
input_model={
"type": "object",
"properties": {
"chart_type": {
"type": "string",
"description": "Type of chart (bar, line, pie, scatter)",
},
"data_points": {
"type": "array",
"items": {"type": "object"},
"description": "Data points for the chart",
},
"title": {
"type": "string",
"description": "Chart title",
},
},
"required": ["chart_type", "data_points", "title"],
},
)
display_timeline = FunctionTool(
name="display_timeline",
description="""Display an interactive timeline (FRONTEND_RENDER).
This tool creates timeline specifications for frontend rendering.
The frontend should render this as an interactive timeline component.""",
func=None, # Makes declaration_only=True so client renders the UI
input_model={
"type": "object",
"properties": {
"events": {
"type": "array",
"items": {"type": "object"},
"description": "Events to display on the timeline",
},
"start_date": {
"type": "string",
"description": "Timeline start date",
},
"end_date": {
"type": "string",
"description": "Timeline end date",
},
},
"required": ["events", "start_date", "end_date"],
},
)
show_comparison_table = FunctionTool(
name="show_comparison_table",
description="""Show a comparison table (FRONTEND_RENDER).
This tool creates table specifications for frontend rendering.
The frontend should render this as an interactive comparison table.""",
func=None, # Makes declaration_only=True so client renders the UI
input_model={
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object"},
"description": "Items to compare",
},
"columns": {
"type": "array",
"items": {"type": "string"},
"description": "Column names",
},
},
"required": ["items", "columns"],
},
)
_UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate content. Never respond with plain text descriptions.
For haiku requests:
- Call generate_haiku tool with all 4 required parameters
- English: 3 lines
- Japanese: 3 lines
- image_name: Choose from available images
- gradient: CSS gradient string
For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table).
"""
OptionsT = TypeVar("OptionsT", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type]
def ui_generator_agent(client: SupportsChatGetResponse[OptionsT]) -> AgentFrameworkAgent:
"""Create a UI generator agent with custom React component rendering.
Args:
client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with UI generation capabilities
"""
agent = Agent(
name="ui_generator",
instructions=_UI_GENERATOR_INSTRUCTIONS,
client=client,
tools=[generate_haiku, create_chart, display_timeline, show_comparison_table],
# Force tool usage - the LLM MUST call a tool, cannot respond with plain text
default_options={"tool_choice": "required"}, # type: ignore
)
return AgentFrameworkAgent(
agent=agent,
name="UIGenerator",
description="Generates custom UI components through tool calls",
)
@@ -0,0 +1,81 @@
# Copyright (c) Microsoft. All rights reserved.
"""Weather agent example demonstrating backend tool rendering."""
from __future__ import annotations
from typing import Any
from agent_framework import Agent, SupportsChatGetResponse, tool
@tool
def get_weather(location: str) -> dict[str, Any]:
"""Get the current weather for a location.
Args:
location: The city or location to get weather for.
Returns:
Weather information as a dictionary with temperatures in Celsius.
"""
# Simulated weather data with structured format (temperatures in Celsius for dojo UI)
weather_data = {
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75, "wind_speed": 12, "feels_like": 10},
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85, "wind_speed": 8, "feels_like": 13},
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60, "wind_speed": 10, "feels_like": 17},
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90, "wind_speed": 5, "feels_like": 32},
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65, "wind_speed": 20, "feels_like": 6},
}
location_lower = location.lower()
if location_lower in weather_data:
return weather_data[location_lower]
return {
"temperature": 21,
"conditions": "partly cloudy",
"humidity": 50,
"wind_speed": 10,
"feels_like": 20,
}
@tool
def get_forecast(location: str, days: int = 3) -> str:
"""Get the weather forecast for a location.
Args:
location: The city or location to get forecast for.
days: Number of days to forecast (default: 3).
Returns:
Forecast information string.
"""
forecast: list[str] = []
for day in range(1, min(days, 7) + 1):
forecast.append(f"Day {day}: Partly cloudy, {60 + day * 2}°F")
return f"{days}-day forecast for {location}:\n" + "\n".join(forecast)
def weather_agent(client: SupportsChatGetResponse[Any]) -> Agent[Any]:
"""Create a weather agent with get_weather and get_forecast tools.
Args:
client: The chat client to use for the agent
Returns:
A configured Agent instance with weather tools
"""
return Agent[Any](
name="weather_agent",
instructions=(
"You are a helpful weather assistant. "
"Use the get_weather and get_forecast functions to help users with weather information. "
"Always provide friendly and informative responses. "
"First return the weather result, and then return details about the forecast."
),
client=client,
tools=[get_weather, get_forecast],
)
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deterministic tool-driven AG-UI state example.
This sample demonstrates how a tool can push a *deterministic* state update
to the AG-UI frontend based on its actual return value — in contrast to
``predict_state_config`` which fires optimistically from LLM-predicted tool
call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167.
The :func:`agent_framework_ag_ui.state_update` helper wraps a text result
together with a state snapshot. When a tool returns one of these, the AG-UI
endpoint merges the snapshot into the shared state and emits a
``StateSnapshotEvent`` after the tool result.
"""
from __future__ import annotations
from typing import Any
from agent_framework import Agent, Content, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
from agent_framework_ag_ui import state_update
# Simulated weather database — in the issue's motivating example the tool
# would instead call a real weather API.
_WEATHER_DB: dict[str, dict[str, Any]] = {
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75},
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85},
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60},
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90},
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65},
}
@tool
async def get_weather(location: str) -> Content:
"""Fetch current weather for a location and push it into AG-UI shared state.
Unlike ``predict_state_config`` — which derives state optimistically from
LLM-predicted tool call arguments — this tool uses ``state_update`` to
forward the *actual* fetched weather to the frontend. The ``text`` goes
back to the LLM as the normal tool result, and the ``state`` dict is merged
into the AG-UI shared state.
Args:
location: City name to look up.
Returns:
A :class:`Content` carrying both the LLM-visible text result and a
deterministic state snapshot.
"""
key = location.lower()
data = _WEATHER_DB.get(
key,
{"temperature": 21, "conditions": "partly cloudy", "humidity": 50},
)
weather_record = {"location": location, **data}
return state_update(
text=(
f"The weather in {location} is {data['conditions']} at "
f"{data['temperature']}°C with {data['humidity']}% humidity."
),
state={"weather": weather_record},
)
def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create an AG-UI agent with a deterministic tool-driven state tool."""
agent = Agent[Any](
name="weather_state_agent",
instructions=(
"You are a weather assistant. When a user asks about the weather "
"in a city, call the get_weather tool and use its output to give a "
"friendly, concise reply. The tool also updates the shared UI state "
"so the frontend can render a weather card from the `weather` key."
),
client=client,
tools=[get_weather],
)
return AgentFrameworkAgent(
agent=agent,
name="WeatherStateAgent",
description="Weather agent that deterministically updates shared state from tool results.",
state_schema={
"weather": {
"type": "object",
"description": "Last fetched weather record",
},
},
)
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,28 @@
# Copyright (c) Microsoft. All rights reserved.
"""Backend tool rendering endpoint."""
from typing import Any, cast
from agent_framework._clients import SupportsChatGetResponse
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.openai import OpenAIChatCompletionClient
from fastapi import FastAPI
from ...agents.weather_agent import weather_agent
def register_backend_tool_rendering(app: FastAPI) -> None:
"""Register the backend tool rendering endpoint.
Args:
app: The FastAPI application.
"""
# Create a chat client and call the factory function
client = cast(SupportsChatGetResponse[Any], OpenAIChatCompletionClient())
add_agent_framework_fastapi_endpoint(
app,
weather_agent(client),
"/backend_tool_rendering",
)
@@ -0,0 +1,173 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example FastAPI server with AG-UI endpoints."""
from __future__ import annotations
import logging
import os
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.openai import OpenAIChatCompletionClient
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from ..agents.document_writer_agent import document_writer_agent
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
from ..agents.recipe_agent import recipe_agent
from ..agents.simple_agent import simple_agent
from ..agents.subgraphs_agent import subgraphs_agent
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
from ..agents.weather_state_agent import weather_state_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")
# Remove any existing handlers
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Configure new handlers
file_handler = logging.FileHandler(log_file, mode="w")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.setLevel(logging.INFO)
# Explicitly set log levels for our modules
logging.getLogger("agent_framework_ag_ui").setLevel(logging.INFO)
logging.getLogger("agent_framework").setLevel(logging.INFO)
logger = logging.getLogger(__name__)
logger.info(f"AG-UI Examples Server starting... Logs writing to: {log_file}")
app = FastAPI(title="Agent Framework AG-UI Example Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Create a shared chat client for all agents
# You can use different chat clients for different agents if needed
# Set CHAT_CLIENT=anthropic to use Anthropic, defaults to Azure OpenAI
client: SupportsChatGetResponse[ChatOptions] = cast(
SupportsChatGetResponse[ChatOptions],
AnthropicClient()
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
else OpenAIChatCompletionClient(),
)
# Agentic Chat - basic chat agent
add_agent_framework_fastapi_endpoint(
app=app,
agent=simple_agent(client),
path="/agentic_chat",
)
# Backend Tool Rendering - agent with tools
add_agent_framework_fastapi_endpoint(
app=app,
agent=weather_agent(client),
path="/backend_tool_rendering",
)
# Shared State - recipe agent with structured output
add_agent_framework_fastapi_endpoint(
app=app,
agent=recipe_agent(client),
path="/shared_state",
)
# Predictive State Updates - document writer with predictive state
add_agent_framework_fastapi_endpoint(
app=app,
agent=document_writer_agent(client),
path="/predictive_state_updates",
)
# Human in the Loop - human-in-the-loop agent with step customization
add_agent_framework_fastapi_endpoint(
app=app,
agent=human_in_the_loop_agent(client),
path="/human_in_the_loop",
state_schema={"steps": {"type": "array"}},
predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}},
)
# Agentic Generative UI - task steps agent with streaming state updates
add_agent_framework_fastapi_endpoint(
app=app,
agent=task_steps_agent_wrapped(client),
path="/agentic_generative_ui",
)
# Tool-based Generative UI - UI generator with frontend-rendered tools
add_agent_framework_fastapi_endpoint(
app=app,
agent=ui_generator_agent(client),
path="/tool_based_generative_ui",
)
# Subgraphs - deterministic travel planner with interrupt-driven selections
add_agent_framework_fastapi_endpoint(
app=app,
agent=subgraphs_agent(),
path="/subgraphs",
)
# Deterministic Tool-Driven State - tool returns state_update() to push snapshot
# from actual tool output (see issue #3167).
add_agent_framework_fastapi_endpoint(
app=app,
agent=weather_state_agent(client),
path="/deterministic_state",
)
def main():
"""Run the server."""
port = int(os.getenv("PORT", "8887"))
host = os.getenv("HOST", "127.0.0.1")
print(f"\nAG-UI Examples Server starting on http://{host}:{port}")
print("Set ENABLE_DEBUG_LOGGING=1 for detailed request logging\n")
# Use log_config=None to prevent uvicorn from reconfiguring logging
# This preserves our file + console logging setup
uvicorn.run(
app,
host=host,
port=port,
log_config=None,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,486 @@
# Getting Started with AG-UI (Python)
The AG-UI (Agent UI) protocol provides a standardized way for client applications to interact with AI agents over HTTP. This tutorial demonstrates how to build both server and client applications using the AG-UI protocol with Python.
## Quick Start - Client Examples
If you want to quickly try out the AG-UI client, we provide three ready-to-use examples:
### Basic Interactive Client (`client.py`)
A simple command-line chat client that demonstrates:
- Streaming responses in real-time
- Automatic thread management for conversation continuity
- Direct `AGUIChatClient` usage (caller manages message history)
**Run:**
```bash
python client.py
```
**Note:** This example sends only the current message to the server. The server is responsible for maintaining conversation history using the thread_id.
### Advanced Features Client (`client_advanced.py`)
Demonstrates advanced capabilities:
- Tool/function calling
- Both streaming and non-streaming responses
- Multi-turn conversations
- Error handling patterns
**Run:**
```bash
python client_advanced.py
```
**Note:** This example shows direct `AGUIChatClient` usage. Tool execution and conversation continuity depend on server-side configuration and capabilities.
### Agent Integration (`client_with_agent.py`)
Best practice example using `Agent` wrapper with **AgentThread**
- **AgentThread** maintains conversation state
- Client-side conversation history management via `thread.message_store`
- **Hybrid tool execution**: client-side + server-side tools simultaneously
- Full conversation history sent on each request
- Tool calling with conversation context
**To demonstrate hybrid tools:**
1. **Start server with server-side tool** (Terminal 1):
```bash
# Server has get_time_zone tool
python server.py
```
2. **Run client with client-side tool** (Terminal 2):
```bash
# Client has get_weather tool
python client_with_agent.py
```
All examples require a running AG-UI server (see Step 1 below for setup).
## Understanding AG-UI Architecture
### Thread Management
The AG-UI protocol supports two approaches to conversation history:
1. **Server-Managed Threads** (client.py, client_advanced.py)
- Client sends only the current message + thread_id
- Server maintains full conversation history
- Requires server to support stateful thread storage
- Lighter network payload
2. **Client-Managed History** (client_with_agent.py)
- Client maintains full conversation history locally
- Full message history sent with each request
- Works with any AG-UI server (stateful or stateless)
The `Agent` wrapper (used in client_with_agent.py) collects messages from local storage and sends the full history to `AGUIChatClient`, which then forwards everything to the server.
### Tool/Function Calling
The AG-UI protocol supports **hybrid tool execution** - both client-side AND server-side tools can coexist in the same conversation.
**The Hybrid Pattern** (client_with_agent.py):
```
Client defines: Server defines:
- get_weather() - get_current_time()
- read_sensors() - get_server_forecast()
User: "What's the weather in SF and what time is it?"
Agent sends: full history + tool definitions for get_weather, read_sensors
Server LLM decides: "I need get_weather('SF') and get_current_time()"
Server executes get_current_time() → "2025-11-11 14:30:00 UTC"
Server sends function call request → get_weather('SF')
Agent intercepts get_weather call → executes locally
Client sends result → "Sunny, 72°F"
Server combines both results → "It's sunny and 72°F in SF, and the current time is 2:30 PM UTC"
Client receives final response
```
**How it works:**
1. **Client-Side Tools** (`client_with_agent.py`):
- Tools defined in Agent's `tools` parameter execute locally
- Tool metadata (name, description, schema) sent to server for planning
- When server requests client tool → client intercepts → executes locally → sends result
2. **Server-Side Tools**:
- Defined in server agent's configuration
- Server executes directly without client involvement
- Results included in server's response
3. **Hybrid Pattern (Both Together)**:
- Server LLM sees ALL tool definitions (client + server)
- Decides which to use based on task
- Server tools execute server-side
- Client tools execute client-side
**Direct AGUIChatClient Usage** (client_advanced.py):
Even without Agent wrapper, client-side tools work:
- Tools passed in ChatOptions execute locally
- Server can also have its own tools
- Hybrid execution works automatically
### Interrupts and Resume Entries
Human-in-the-loop approvals and workflow input requests pause by emitting a terminal `RUN_FINISHED` event whose
`outcome.type` is `"interrupt"`. Generic AG-UI clients should read prompts from `RUN_FINISHED.outcome.interrupts`
and resume the same `threadId` with a canonical `resume` array of `ResumeEntry` values.
```json
{
"threadId": "thread-1",
"messages": [],
"resume": [
{
"interruptId": "approval_1",
"status": "resolved",
"payload": {
"approved": true
}
}
]
}
```
`Interrupt` and `ResumeEntry` are AG-UI protocol models from `ag_ui.core`; Agent Framework does not define a
separate interrupt model. New interrupted runs use `RUN_FINISHED.outcome.interrupts`, not a stable top-level
`RUN_FINISHED.interrupt` field.
## What is AG-UI?
AG-UI is a protocol that enables:
- **Remote agent hosting**: Host AI agents as web services that can be accessed by multiple clients
- **Streaming responses**: Real-time streaming of agent responses using Server-Sent Events (SSE)
- **Standardized communication**: Consistent message format for agent interactions
- **Thread management**: Maintain conversation context across multiple requests
- **Advanced features**: Human-in-the-loop, state management, tool rendering
## Prerequisites
Before you begin, ensure you have the following:
- Python 3.10 or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for DefaultAzureCredential)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
**Note**: These samples use `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, or environment variables). For more information, see the [Azure Identity documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential).
> **Warning**
> The AG-UI protocol is still under development and subject to change.
> We will keep these samples updated as the protocol evolves.
## Step 1: Creating an AG-UI Server
The AG-UI server hosts your AI agent and exposes it via HTTP endpoints using FastAPI.
### Install Required Packages
```bash
pip install agent-framework-ag-ui
```
Or using uv:
```bash
uv pip install agent-framework-ag-ui
```
### Server Code
Create a file named `server.py`:
```python
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI server example."""
import os
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
model = os.environ.get("AZURE_OPENAI_MODEL")
api_key = os.environ.get("AZURE_OPENAI_API_KEY")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not model:
raise ValueError("AZURE_OPENAI_MODEL environment variable is required")
if not api_key:
raise ValueError("AZURE_OPENAI_API_KEY environment variable is required")
# Create the AI agent
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
client=OpenAIChatCompletionClient(
azure_endpoint=endpoint,
model=model,
api_key=api_key,
),
)
# Create FastAPI app
app = FastAPI(title="AG-UI Server")
# Register the AG-UI endpoint
add_agent_framework_fastapi_endpoint(app, agent, "/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=5100)
```
### Key Concepts
- **`add_agent_framework_fastapi_endpoint`**: Registers the AG-UI endpoint with automatic request/response handling and SSE streaming
- **`Agent`**: The agent that will handle incoming requests
- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses
- **Instructions**: The agent is created with default instructions, which can be overridden by client messages
- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_MODEL`, `AZURE_OPENAI_API_KEY`) or accept parameters directly
**Alternative (simpler)**: Use environment variables only:
```python
# No need to read environment variables manually
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
client=OpenAIChatCompletionClient(), # Reads from environment automatically
)
```
### Configure and Run the Server
Set the required environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_MODEL="gpt-4o-mini"
# Optional: Set API key if not using DefaultAzureCredential
# export AZURE_OPENAI_API_KEY="your-api-key"
```
Run the server:
```bash
python server.py
```
Or using uvicorn directly:
```bash
uvicorn server:app --host 127.0.0.1 --port 5100
```
The server will start listening on `http://127.0.0.1:5100`.
## Step 2: Creating an AG-UI Client
The AG-UI client connects to the remote server and displays streaming responses. The `AGUIChatClient` is a built-in implementation that integrates with the Agent Framework's standard chat interface.
### Install Required Packages
The `AGUIChatClient` is included in the `agent-framework-ag-ui` package (already installed if you installed the server packages).
```bash
pip install agent-framework-ag-ui
```
### Client Code
Create a file named `client.py`:
```python
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example using AGUIChatClient."""
import asyncio
import os
from agent_framework.ag_ui import AGUIChatClient
async def main():
"""Main client loop demonstrating AGUIChatClient usage."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
# Create client with context manager for automatic cleanup
async with AGUIChatClient(endpoint=server_url) as client:
thread_id: str | None = None
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
# Send message and stream the response
print("\nAssistant: ", end="", flush=True)
# Use metadata to maintain conversation continuity
metadata = {"thread_id": thread_id} if thread_id else None
async for update in client.get_response(message, metadata=metadata, stream=True):
# Extract thread ID from first update
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
if thread_id:
print(f"\n[Thread: {thread_id}]")
print("Assistant: ", end="", flush=True)
# Stream text content as it arrives
for content in update.contents:
if content.type == "text" and content.text:
print(content.text, end="", flush=True)
print() # New line after response
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\nAn error occurred: {e}")
if __name__ == "__main__":
asyncio.run(main())
```
### Key Concepts
- **`AGUIChatClient`**: Built-in client that implements the Agent Framework's `BaseChatClient` interface
- **Automatic Event Handling**: The client automatically converts AG-UI events to Agent Framework types
- **Thread Management**: Pass `thread_id` in metadata to maintain conversation context across requests
- **Streaming Responses**: Use `get_response(..., stream=True)` for real-time streaming or `get_response(..., stream=False)` for non-streaming
- **Context Manager**: Use `async with` for automatic cleanup of HTTP connections
- **Standard Interface**: Works with all Agent Framework patterns (Agent, tools, etc.)
- **Hybrid Tool Execution**: Supports both client-side and server-side tools executing together in the same conversation
### Configure and Run the Client
Optionally set a custom server URL:
```bash
export AGUI_SERVER_URL="http://127.0.0.1:5100/"
```
Run the client (in a separate terminal):
```bash
python client.py
```
## Step 3: Testing the Complete System
### Expected Output
```
$ python client.py
Connecting to AG-UI server at: http://127.0.0.1:5100/
User (:q or quit to exit): What is the capital of France?
[Thread: abc123]
Assistant: The capital of France is Paris. It is known for its rich history, culture,
and iconic landmarks such as the Eiffel Tower and the Louvre Museum.
User (:q or quit to exit): Tell me a fun fact about space
```
## Troubleshooting
### Connection Refused
Ensure the server is running before starting the client:
```bash
# Terminal 1
python server.py
# Terminal 2 (after server starts)
python client.py
```
### Authentication Errors
Make sure you're authenticated with Azure:
```bash
az login
```
Verify you have the correct role assignment on the Azure OpenAI resource.
### Streaming Not Working
Check that your client timeout is sufficient:
```python
httpx.AsyncClient(timeout=60.0) # 60 seconds should be enough
```
For long-running agents, increase the timeout accordingly.
### No Events Received
Ensure you're using the correct `Accept` header:
```python
headers={"Accept": "text/event-stream"}
```
And parsing SSE format correctly (lines starting with `data: `).
### Thread Context Lost
The client automatically manages thread continuity. If context is lost:
1. Check that `threadId` is being captured from `RUN_STARTED` events
2. Ensure the same client instance is used across messages
3. Verify the server is receiving the `thread_id` in subsequent requests
### Event Type Mismatches
Remember that event types are UPPERCASE with underscores (`RUN_STARTED`, not `run_started`) and field names are camelCase (`threadId`, not `thread_id`).
### Import Errors
Make sure all packages are installed:
```bash
pip install agent-framework-ag-ui agent-framework-core fastapi uvicorn httpx
```
Or check your virtual environment is activated:
```bash
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
```
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI client example using AGUIChatClient.
This example demonstrates how to use the AGUIChatClient to connect to
a remote AG-UI server and interact with it using the Agent Framework's
standard chat interface.
"""
import asyncio
import os
from typing import cast
from agent_framework import ChatResponse, ChatResponseUpdate, Message, ResponseStream
from agent_framework.ag_ui import AGUIChatClient
async def main():
"""Main client loop demonstrating AGUIChatClient usage."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print(f"Connecting to AG-UI server at: {server_url}\n")
print("Using AGUIChatClient with automatic thread management and Agent Framework integration.\n")
# Create client with context manager for automatic cleanup
async with AGUIChatClient(endpoint=server_url) as client:
thread_id: str | None = None
try:
while True:
# Get user input
message = input("\nUser (:q or quit to exit): ")
if not message.strip():
print("Request cannot be empty.")
continue
if message.lower() in (":q", "quit"):
break
# Send message and stream the response
print("\nAssistant: ", end="", flush=True)
# Use metadata to maintain conversation continuity
metadata = {"thread_id": thread_id} if thread_id else None
stream = client.get_response(
[Message(role="user", contents=[message])],
stream=True,
options={"metadata": metadata} if metadata else None,
)
stream = cast(ResponseStream[ChatResponseUpdate, ChatResponse], stream)
async for update in stream:
# Extract and display thread ID from first update
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
if thread_id:
print(f"\n\033[93m[Thread: {thread_id}]\033[0m", end="", flush=True)
print("\nAssistant: ", end="", flush=True)
# Display text content as it streams
for content in update.contents:
if content.type == "text" and content.text:
print(f"\033[96m{content.text}\033[0m", end="", flush=True)
# Display finish reason if present
if update.finish_reason:
print(f"\n\033[92m[Finished: {update.finish_reason}]\033[0m", end="", flush=True)
print() # New line after response
except KeyboardInterrupt:
print("\n\nExiting...")
except Exception as e:
print(f"\n\033[91mAn error occurred: {e}\033[0m")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,245 @@
# Copyright (c) Microsoft. All rights reserved.
"""Advanced AG-UI client example with tools and features.
This example demonstrates advanced AGUIChatClient features including:
- Tool/function calling
- Non-streaming responses
- Multiple conversation turns
- Error handling
"""
from __future__ import annotations
import asyncio
import os
from typing import cast
from agent_framework import ChatResponse, ChatResponseUpdate, Message, ResponseStream, tool
from agent_framework.ag_ui import AGUIChatClient
@tool
def get_weather(location: str) -> str:
"""Get the current weather for a location.
Args:
location: The city or location name
"""
# Simulate weather lookup
weather_data = {
"seattle": "Rainy, 55°F",
"san francisco": "Foggy, 62°F",
"new york": "Sunny, 68°F",
"london": "Cloudy, 52°F",
}
return weather_data.get(location.lower(), f"Weather data not available for {location}")
@tool
def calculate(a: float, b: float, operation: str) -> str:
"""Perform basic arithmetic operations.
Args:
a: First number
b: Second number
operation: Operation to perform (add, subtract, multiply, divide)
"""
try:
if operation == "add":
result = a + b
elif operation == "subtract":
result = a - b
elif operation == "multiply":
result = a * b
elif operation == "divide":
result = a / b
else:
return f"Unsupported operation: {operation}"
return f"The result is: {result}"
except Exception as e:
return f"Error calculating: {e}"
async def streaming_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate streaming responses."""
print("\n" + "=" * 60)
print("STREAMING EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: Tell me a short joke\n")
print("Assistant: ", end="", flush=True)
stream = client.get_response(
[Message(role="user", contents=["Tell me a short joke"])],
stream=True,
options={"metadata": metadata} if metadata else None,
)
stream = cast(ResponseStream[ChatResponseUpdate, ChatResponse], stream)
async for update in stream:
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
for content in update.contents:
if content.type == "text" and content.text: # type: ignore[attr-defined]
print(content.text, end="", flush=True) # type: ignore[attr-defined]
print("\n")
return thread_id
async def non_streaming_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate non-streaming responses."""
print("\n" + "=" * 60)
print("NON-STREAMING EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: What is 2 + 2?\n")
response = await client.get_response([Message(role="user", contents=["What is 2 + 2?"])], metadata=metadata)
print(f"Assistant: {response.text}")
if response.additional_properties:
thread_id = response.additional_properties.get("thread_id")
print(f"\n[Thread: {thread_id}]")
return thread_id
async def tool_example(client: AGUIChatClient, thread_id: str | None = None):
"""Demonstrate sending tool definitions to the server.
IMPORTANT: When using AGUIChatClient directly (without Agent wrapper):
- Tools are sent as DEFINITIONS only
- No automatic client-side execution (no function invocation middleware)
- Server must have matching tool implementations to execute them
For CLIENT-SIDE tool execution (like .NET AGUIClient sample):
- Use Agent wrapper with tools
- See client_with_agent.py for the hybrid pattern
- Agent middleware intercepts and executes client tools locally
- Server can have its own tools that execute server-side
- Both client and server tools work together in same conversation
This example sends tool definitions and assumes server-side execution.
"""
print("\n" + "=" * 60)
print("TOOL DEFINITION EXAMPLE")
print("=" * 60)
metadata = {"thread_id": thread_id} if thread_id else None
print("\nUser: What's the weather in Seattle?\n")
print("Sending tool definitions to server...")
print("(Server must be configured with matching tools to execute them)\n")
response = await client.get_response(
[Message(role="user", contents=["What's the weather in Seattle?"])],
tools=[get_weather, calculate],
metadata=metadata,
)
print(f"Assistant: {response.text}")
# Show tool calls if any
tool_called = False
for message in response.messages:
for content in message.contents:
if content.type == "function_call": # type: ignore[attr-defined]
print(f"\n[Tool Called: {content.name}]") # type: ignore[attr-defined]
tool_called = True
if not tool_called:
print("\n[Note: No tools were called - server may not be configured for tool execution]")
if response.additional_properties:
thread_id = response.additional_properties.get("thread_id")
return thread_id
async def conversation_example(client: AGUIChatClient):
"""Demonstrate multi-turn conversation.
Note: Conversation continuity depends on the server maintaining thread state.
Some servers may require explicit message history to be sent with each request.
"""
print("\n" + "=" * 60)
print("MULTI-TURN CONVERSATION EXAMPLE")
print("=" * 60)
print("\nNote: This example uses thread_id for context. Server must support thread-based state.\n")
# First turn
print("User: My name is Alice\n")
response1 = await client.get_response([Message(role="user", contents=["My name is Alice"])])
print(f"Assistant: {response1.text}")
thread_id = response1.additional_properties.get("thread_id")
print(f"\n[Thread: {thread_id}]")
# Second turn - using same thread
print("\nUser: What's my name?\n")
response2 = await client.get_response(
[Message(role="user", contents=["What's my name?"])], options={"metadata": {"thread_id": thread_id}}
)
print(f"Assistant: {response2.text}")
# Check if context was maintained
if "alice" not in response2.text.lower():
print("\n[Note: Server may not maintain thread context - consider using Agent for history management]")
# Third turn
print("\nUser: Can you also tell me what 10 * 5 is?\n")
response3 = await client.get_response(
[Message(role="user", contents=["Can you also tell me what 10 * 5 is?"])],
options={"metadata": {"thread_id": thread_id}},
tools=[calculate],
)
print(f"Assistant: {response3.text}")
async def main():
"""Run all examples."""
# Get server URL from environment or use default
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print("=" * 60)
print("AG-UI Chat Client Advanced Examples")
print("=" * 60)
print(f"\nServer: {server_url}")
print("\nThese examples demonstrate various AGUIChatClient features:")
print(" 1. Streaming responses")
print(" 2. Non-streaming responses")
print(" 3. Tool/function calling")
print(" 4. Multi-turn conversations")
try:
async with AGUIChatClient(endpoint=server_url) as client:
# Run examples in sequence
thread_id = await streaming_example(client)
thread_id = await non_streaming_example(client, thread_id)
await tool_example(client, thread_id)
# Separate conversation with new thread
await conversation_example(client)
print("\n" + "=" * 60)
print("All examples completed successfully!")
print("=" * 60)
except ConnectionError as e:
print(f"\n\033[91mConnection Error: {e}\033[0m")
print("\nMake sure an AG-UI server is running at the specified endpoint.")
except Exception as e:
print(f"\n\033[91mError: {e}\033[0m")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,150 @@
# Copyright (c) Microsoft. All rights reserved.
"""Example showing Agent with AGUIChatClient for hybrid tool execution.
This demonstrates the HYBRID pattern matching .NET AGUIClient implementation:
1. AgentSession Pattern (like .NET):
- Create session with agent.create_session()
- Pass session to agent.run(stream=True) on each turn
- Session maintains conversation context via context providers
2. Hybrid Tool Execution:
- AGUIChatClient uses function invocation mixin
- Client-side tools (get_weather) can execute locally when server requests them
- Server may also have its own tools that execute server-side
- Both work together: server LLM decides which tool to call, decorator handles client execution
This matches .NET pattern: session maintains state, tools execute on appropriate side.
"""
from __future__ import annotations
import asyncio
import logging
import os
from agent_framework import Agent, tool
from agent_framework.ag_ui import AGUIChatClient
# Enable debug logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@tool(description="Get the current weather for a location.")
def get_weather(location: str) -> str:
"""Get the current weather for a location.
Args:
location: The city or location name
"""
print(f"[CLIENT] get_weather tool called with location: {location}")
weather_data = {
"seattle": "Rainy, 55°F",
"san francisco": "Foggy, 62°F",
"new york": "Sunny, 68°F",
"london": "Cloudy, 52°F",
}
result = weather_data.get(location.lower(), f"Weather data not available for {location}")
print(f"[CLIENT] get_weather returning: {result}")
return result
async def main():
"""Demonstrate Agent + AGUIChatClient hybrid tool execution.
This matches the .NET pattern from Program.cs where:
- AIAgent agent = chatClient.CreateAIAgent(tools: [...])
- AgentSession session = agent.CreateSession()
- RunStreamingAsync(messages, session)
Python equivalent:
- agent = Agent(client=AGUIChatClient(...), tools=[...])
- session = agent.create_session() # Creates session
- agent.run(message, stream=True, session=session) # Session tracks context
"""
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
print("=" * 70)
print("Agent + AGUIChatClient: Hybrid Tool Execution")
print("=" * 70)
print(f"\nServer: {server_url}")
print("\nThis example demonstrates:")
print(" 1. AgentSession maintains conversation state (like .NET)")
print(" 2. Client-side tools execute locally via function invocation mixin")
print(" 3. Server may have additional tools that execute server-side")
print(" 4. HYBRID: Client and server tools work together simultaneously\n")
try:
# Create remote client in async context manager
async with AGUIChatClient(endpoint=server_url) as remote_client:
# Wrap in Agent for conversation history management
agent = Agent(
name="remote_assistant",
instructions="You are a helpful assistant. Remember user information across the conversation.",
client=remote_client,
tools=[get_weather],
)
# Create a session to maintain conversation state (like .NET AgentSession)
session = agent.create_session()
print("=" * 70)
print("CONVERSATION WITH HISTORY")
print("=" * 70)
# Turn 1: Introduce
print("\nUser: My name is Alice and I live in Seattle\n")
async for chunk in agent.run("My name is Alice and I live in Seattle", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 2: Ask about name (tests history)
print("User: What's my name?\n")
async for chunk in agent.run("What's my name?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 3: Ask about location (tests history)
print("User: Where do I live?\n")
async for chunk in agent.run("Where do I live?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 4: Test client-side tool (get_weather is client-side)
print("User: What's the weather forecast for today in Seattle?\n")
async for chunk in agent.run(
"What's the weather forecast for today in Seattle?",
stream=True,
session=session,
):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 5: Test server-side tool (get_time_zone is server-side only)
print("User: What time zone is Seattle in?\n")
async for chunk in agent.run("What time zone is Seattle in?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
except ConnectionError as e:
print(f"\n\033[91mConnection Error: {e}\033[0m")
print("\nMake sure an AG-UI server is running at the specified endpoint.")
except Exception as e:
print(f"\n\033[91mError: {e}\033[0m")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,144 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI server example with server-side tools."""
from __future__ import annotations
import logging
import os
from agent_framework import Agent, tool
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.openai import OpenAIChatCompletionClient
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
load_dotenv()
# Enable debug logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
model = os.environ.get("AZURE_OPENAI_MODEL")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not model:
raise ValueError("AZURE_OPENAI_MODEL environment variable is required")
# ============================================================================
# AUTHENTICATION EXAMPLE
# ============================================================================
# This demonstrates how to secure the AG-UI endpoint with API key authentication.
# In production, you should use a more robust authentication mechanism such as:
# - OAuth 2.0 / OpenID Connect
# - JWT tokens with proper validation
# - Azure AD / Entra ID integration
# - Your organization's identity provider
#
# The API key should be stored securely (e.g., Azure Key Vault, environment variables)
# and rotated regularly.
# ============================================================================
# API key header configuration
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
# Get the expected API key from environment variable
# In production, use a secrets manager like Azure Key Vault
EXPECTED_API_KEY = os.environ.get("AG_UI_API_KEY")
async def verify_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> None:
"""Verify the API key provided in the request header.
Args:
api_key: The API key from the X-API-Key header
Raises:
HTTPException: If the API key is missing or invalid
"""
if not EXPECTED_API_KEY:
# If no API key is configured, log a warning but allow the request
# This maintains backward compatibility but warns about the security risk
logger.warning(
"AG_UI_API_KEY environment variable not set. "
"The endpoint is accessible without authentication. "
"Set AG_UI_API_KEY to enable API key authentication."
)
return
if not api_key:
raise HTTPException(
status_code=401,
detail="Missing API key. Provide X-API-Key header.",
)
if api_key != EXPECTED_API_KEY:
raise HTTPException(
status_code=403,
detail="Invalid API key.",
)
# Server-side tool (executes on server)
@tool(description="Get the time zone for a location.")
def get_time_zone(location: str) -> str:
"""Get the time zone for a location.
Args:
location: The city or location name
"""
print(f"[SERVER] get_time_zone tool called with location: {location}")
timezone_data = {
"seattle": "Pacific Time (UTC-8)",
"san francisco": "Pacific Time (UTC-8)",
"new york": "Eastern Time (UTC-5)",
"london": "Greenwich Mean Time (UTC+0)",
}
result = timezone_data.get(location.lower(), f"Time zone data not available for {location}")
print(f"[SERVER] get_time_zone returning: {result}")
return result
# Create the AI agent with ONLY server-side tools
# IMPORTANT: Do NOT include tools that the client provides!
# In this example:
# - get_time_zone: SERVER-ONLY tool (only server has this)
# - get_weather: CLIENT-ONLY tool (client provides this, server should NOT include it)
# The client will send get_weather tool metadata so the LLM knows about it,
# and the function invocation mixin on AGUIChatClient will execute it client-side.
# This matches the .NET AG-UI hybrid execution pattern.
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.",
client=OpenAIChatCompletionClient(
azure_endpoint=endpoint,
model=model,
),
tools=[get_time_zone], # ONLY server-side tools
)
# Create FastAPI app
app = FastAPI(title="AG-UI Server")
# Register the AG-UI endpoint with authentication
# The dependencies parameter accepts FastAPI Depends() objects that run before the handler
add_agent_framework_fastapi_endpoint(
app,
agent,
"/",
dependencies=[Depends(verify_api_key)],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=5100, log_level="debug", access_log=True)
+82
View File
@@ -0,0 +1,82 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0rc8"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
requires-python = ">=3.10"
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"ag-ui-protocol>=0.1.19,<0.2",
"fastapi>=0.121.0,<0.138.1",
"sse-starlette>=3.4.5,<4",
"uvicorn[standard]>=0.30.0,<1"
]
[project.optional-dependencies]
dev = [
"pytest==9.1.1",
"httpx==0.28.1",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["agent_framework_ag_ui", "agent_framework_ag_ui_examples"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests/ag_ui"]
pythonpath = [".", "tests/ag_ui"]
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
[tool.pyright]
include = ["agent_framework_ag_ui"]
exclude = ["tests", "tests/ag_ui", "examples"]
typeCheckingMode = "basic"
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[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'
@@ -0,0 +1,350 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared test fixtures and stubs for AG-UI tests."""
import sys
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping, MutableSequence, Sequence
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Generic, Literal, TypedDict, cast, overload # noqa: F401
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentSession,
BaseChatClient,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
Message,
ServiceSessionId,
SupportsAgentRun,
SupportsChatGetResponse,
)
from agent_framework._clients import OptionsCoT
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
from agent_framework._types import ResponseStream
from agent_framework.observability import ChatTelemetryLayer
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
StreamFn = Callable[..., AsyncIterable[ChatResponseUpdate]]
ResponseFn = Callable[..., Awaitable[ChatResponse]]
def pytest_configure() -> None:
"""Ensure this test directory is on sys.path so helper modules can be imported by name."""
test_dir = str(Path(__file__).resolve().parent)
if test_dir not in sys.path:
sys.path.insert(0, test_dir)
class StreamingChatClientStub(
FunctionInvocationLayer[OptionsCoT],
ChatMiddlewareLayer[OptionsCoT],
ChatTelemetryLayer[OptionsCoT],
BaseChatClient[OptionsCoT],
Generic[OptionsCoT],
):
"""Typed streaming stub that satisfies SupportsChatGetResponse."""
def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None:
super().__init__(middleware=[])
self._stream_fn = stream_fn
self._response_fn = response_fn
self.last_session: AgentSession | None = None
self.last_service_session_id: str | ServiceSessionId | None = None
@overload
def get_response(
self,
messages: Sequence[Message],
*,
stream: Literal[False] = ...,
options: ChatOptions[Any],
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@overload
def get_response(
self,
messages: Sequence[Message],
*,
stream: Literal[False] = ...,
options: OptionsCoT | ChatOptions[None] | None = ...,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]]: ...
@overload
def get_response(
self,
messages: Sequence[Message],
*,
stream: Literal[True],
options: OptionsCoT | ChatOptions[Any] | None = ...,
**kwargs: Any,
) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ...
def get_response(
self,
messages: Sequence[Message],
*,
stream: bool = False,
options: OptionsCoT | ChatOptions[Any] | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
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
if stream:
return super().get_response(
messages=messages,
stream=True,
options=options,
**kwargs,
)
return super().get_response(
messages=messages,
stream=False,
options=options,
**kwargs,
)
@override
def _inner_get_response(
self,
*,
messages: Sequence[Message],
stream: bool = False,
options: Mapping[str, Any],
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse.from_updates(updates)
return ResponseStream(self._stream_fn(messages, options, **kwargs), finalizer=_finalize)
return self._get_response_impl(messages, options, **kwargs)
async def _get_response_impl(
self, messages: Sequence[Message], options: Mapping[str, Any], **kwargs: Any
) -> ChatResponse:
"""Non-streaming implementation."""
if self._response_fn is not None:
return await self._response_fn(messages, options, **kwargs)
contents: list[Any] = []
async for update in self._stream_fn(list(messages), dict(options), **kwargs):
contents.extend(update.contents)
return ChatResponse(
messages=[Message(role="assistant", contents=contents)],
response_id="stub-response",
)
def stream_from_updates(updates: list[ChatResponseUpdate]) -> StreamFn:
"""Create a stream function that yields from a static list of updates."""
async def _stream(
messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
for update in updates:
yield update
return _stream
class StubAgent(SupportsAgentRun):
"""Minimal SupportsAgentRun stub for orchestrator tests."""
def __init__(
self,
updates: list[AgentResponseUpdate] | None = None,
*,
agent_id: str = "stub-agent",
agent_name: str | None = "stub-agent",
default_options: Any | None = None,
client: Any | None = None,
) -> None:
self.id = agent_id
self.name = agent_name
self.description = "stub agent"
self.updates = updates or [AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")]
self.default_options: dict[str, Any] = (
default_options if isinstance(default_options, dict) else {"tools": None, "response_format": None}
)
self.client = client or SimpleNamespace(function_invocation_configuration=None)
self.messages_received: list[Any] = []
self.tools_received: list[Any] | None = None
self.last_session: AgentSession | None = None
@overload
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: Literal[False] = ...,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: Literal[True],
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
if messages is None:
self.messages_received = []
elif isinstance(messages, (str, Content, Message)):
self.messages_received = [messages]
else:
self.messages_received = list(messages)
self.last_session = session
self.tools_received = kwargs.get("tools")
for update in self.updates:
yield update
def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse:
return AgentResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get_response() -> AgentResponse[Any]:
return AgentResponse(messages=[], response_id="stub-response")
return _get_response()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession(session_id=kwargs.get("session_id"))
def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession:
return AgentSession(session_id=session_id, service_session_id=service_session_id)
# Fixtures
@pytest.fixture
def streaming_chat_client_stub() -> type[SupportsChatGetResponse]:
"""Return the StreamingChatClientStub class for creating test instances."""
return StreamingChatClientStub # type: ignore[return-value]
@pytest.fixture
def stream_from_updates_fixture() -> Callable[[list[ChatResponseUpdate]], StreamFn]:
"""Return the stream_from_updates helper function."""
return stream_from_updates
@pytest.fixture
def stub_agent() -> type[SupportsAgentRun]:
"""Return the StubAgent class for creating test instances."""
return StubAgent # type: ignore[return-value]
# ── Fixtures for golden / integration tests ──
@pytest.fixture
def collect_events() -> Callable[..., Any]:
"""Return an async helper that collects all events from an async generator."""
async def _collect(async_gen: AsyncIterable[Any]) -> list[Any]:
return [event async for event in async_gen]
return _collect
@pytest.fixture
def make_agent_wrapper() -> Callable[..., Any]:
"""Factory that builds an AgentFrameworkAgent from a stream function.
Usage::
agent = make_agent_wrapper(
stream_fn=stream_from_updates(updates),
state_schema=...,
)
events = [e async for e in agent.run(payload)]
"""
from agent_framework_ag_ui import AgentFrameworkAgent
def _factory(
stream_fn: StreamFn,
*,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
) -> Any:
client = StreamingChatClientStub(stream_fn)
stub = StubAgent(client=client)
return AgentFrameworkAgent(
agent=stub,
state_schema=state_schema,
predict_state_config=predict_state_config,
require_confirmation=require_confirmation,
)
return _factory
@pytest.fixture
def make_app() -> Callable[..., Any]:
"""Factory that builds a FastAPI app with an AG-UI endpoint.
Usage::
app = make_app(agent_or_wrapper, path="/test")
"""
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
def _factory(
agent: Any,
*,
path: str = "/",
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
default_state: dict[str, Any] | None = None,
) -> FastAPI:
app = FastAPI()
add_agent_framework_fastapi_endpoint(
app,
agent,
path=path,
state_schema=state_schema,
predict_state_config=predict_state_config,
default_state=default_state,
)
return app
return _factory
@@ -0,0 +1,210 @@
# Copyright (c) Microsoft. All rights reserved.
"""EventStream assertion helper for AG-UI regression tests."""
from __future__ import annotations
from typing import Any
class EventStream:
"""Wraps a list of AG-UI events with structured assertion methods.
Usage:
events = [event async for event in agent.run(payload)]
stream = EventStream(events)
stream.assert_bookends()
stream.assert_text_messages_balanced()
"""
def __init__(self, events: list[Any]) -> None:
self.events = events
def __len__(self) -> int:
return len(self.events)
def __iter__(self):
return iter(self.events)
def types(self) -> list[str]:
"""Return ordered list of event type strings."""
return [self._type_str(e) for e in self.events]
def get(self, event_type: str) -> list[Any]:
"""Filter events matching the given type string."""
return [e for e in self.events if self._type_str(e) == event_type]
def first(self, event_type: str) -> Any:
"""Return the first event matching the given type, or raise."""
matches = self.get(event_type)
if not matches:
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
return matches[0]
def last(self, event_type: str) -> Any:
"""Return the last event matching the given type, or raise."""
matches = self.get(event_type)
if not matches:
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
return matches[-1]
def snapshot(self) -> dict[str, Any]:
"""Return the latest StateSnapshotEvent snapshot dict."""
return self.last("STATE_SNAPSHOT").snapshot
def messages_snapshot(self) -> list[Any]:
"""Return the latest MessagesSnapshotEvent messages list."""
return self.last("MESSAGES_SNAPSHOT").messages
def run_finished_interrupts(self, event: Any | None = None) -> list[dict[str, Any]]:
"""Return canonical interrupts from a RUN_FINISHED event."""
target = event or self.last("RUN_FINISHED")
dumped = self._event_dump(target)
assert "interrupt" not in dumped
outcome = dumped.get("outcome")
assert isinstance(outcome, dict), f"Expected RUN_FINISHED.outcome, got {dumped}"
assert outcome.get("type") == "interrupt"
interrupts = outcome.get("interrupts")
assert isinstance(interrupts, list), f"Expected outcome.interrupts, got {outcome}"
return interrupts
@staticmethod
def interrupt_metadata_value(interrupt: dict[str, Any]) -> dict[str, Any]:
"""Return Agent Framework interruption details from canonical interrupt metadata."""
metadata = interrupt.get("metadata")
assert isinstance(metadata, dict)
agent_framework_metadata = metadata.get("agent_framework")
assert isinstance(agent_framework_metadata, dict)
value = agent_framework_metadata.get("value")
assert isinstance(value, dict)
return value
# ── Structural assertions ──
def assert_bookends(self) -> None:
"""Assert first event is RUN_STARTED and last is RUN_FINISHED."""
types = self.types()
assert types, "Event stream is empty"
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
assert types[-1] == "RUN_FINISHED", f"Expected RUN_FINISHED last, got {types[-1]}"
def assert_has_run_lifecycle(self) -> None:
"""Assert RUN_STARTED is first and RUN_FINISHED exists (may not be last).
Use this instead of assert_bookends() for workflow resume streams where
_drain_open_message() can emit TEXT_MESSAGE_END after RUN_FINISHED.
"""
types = self.types()
assert types, "Event stream is empty"
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
assert "RUN_FINISHED" in types, f"Expected RUN_FINISHED in stream. Types: {types}"
def assert_strict_types(self, expected: list[str]) -> None:
"""Assert exact type sequence match."""
actual = self.types()
assert actual == expected, f"Event type mismatch.\nExpected: {expected}\nActual: {actual}"
def assert_ordered_types(self, expected: list[str]) -> None:
"""Assert expected types appear as a subsequence (in order, not necessarily contiguous)."""
actual = self.types()
actual_idx = 0
for expected_type in expected:
found = False
while actual_idx < len(actual):
if actual[actual_idx] == expected_type:
actual_idx += 1
found = True
break
actual_idx += 1
if not found:
raise AssertionError(
f"Expected subsequence type {expected_type!r} not found after index {actual_idx}.\n"
f"Expected subsequence: {expected}\n"
f"Actual types: {actual}"
)
def assert_text_messages_balanced(self) -> None:
"""Assert every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END with the same message_id."""
starts: dict[str, int] = {}
ends: set[str] = set()
for i, event in enumerate(self.events):
t = self._type_str(event)
if t == "TEXT_MESSAGE_START":
mid = event.message_id
assert mid not in starts, f"Duplicate TEXT_MESSAGE_START for message_id={mid}"
starts[mid] = i
elif t == "TEXT_MESSAGE_END":
mid = event.message_id
assert mid in starts, f"TEXT_MESSAGE_END for unknown message_id={mid}"
assert mid not in ends, f"Duplicate TEXT_MESSAGE_END for message_id={mid}"
ends.add(mid)
unclosed = set(starts.keys()) - ends
assert not unclosed, f"Unclosed text messages: {unclosed}"
def assert_tool_calls_balanced(self) -> None:
"""Assert every TOOL_CALL_START has a matching TOOL_CALL_END with the same tool_call_id."""
starts: dict[str, int] = {}
ends: set[str] = set()
for i, event in enumerate(self.events):
t = self._type_str(event)
if t == "TOOL_CALL_START":
tid = event.tool_call_id
assert tid not in starts, f"Duplicate TOOL_CALL_START for tool_call_id={tid}"
starts[tid] = i
elif t == "TOOL_CALL_END":
tid = event.tool_call_id
assert tid in starts, f"TOOL_CALL_END for unknown tool_call_id={tid}"
assert tid not in ends, f"Duplicate TOOL_CALL_END for tool_call_id={tid}"
ends.add(tid)
unclosed = set(starts.keys()) - ends
assert not unclosed, f"Unclosed tool calls: {unclosed}"
def assert_no_run_error(self) -> None:
"""Assert no RUN_ERROR events exist."""
errors = self.get("RUN_ERROR")
if errors:
messages = [getattr(e, "message", str(e)) for e in errors]
raise AssertionError(f"Found {len(errors)} RUN_ERROR event(s): {messages}")
def assert_has_type(self, event_type: str) -> None:
"""Assert at least one event of the given type exists."""
assert event_type in self.types(), f"Expected {event_type!r} in stream. Available: {self.types()}"
def assert_message_ids_consistent(self) -> None:
"""Assert TEXT_MESSAGE_CONTENT events reference valid, open message_ids."""
open_messages: set[str] = set()
for event in self.events:
t = self._type_str(event)
if t == "TEXT_MESSAGE_START":
open_messages.add(event.message_id)
elif t == "TEXT_MESSAGE_END":
open_messages.discard(event.message_id)
elif t == "TEXT_MESSAGE_CONTENT":
mid = event.message_id
assert mid in open_messages, f"TEXT_MESSAGE_CONTENT references message_id={mid} which is not open"
# ── Internal ──
@staticmethod
def _type_str(event: Any) -> str:
"""Extract event type as a plain string."""
t = getattr(event, "type", None)
if t is None:
return type(event).__name__
if isinstance(t, str):
return t
return getattr(t, "value", str(t))
@staticmethod
def _event_dump(event: Any) -> dict[str, Any]:
"""Serialize an event object or return a raw event dict."""
if isinstance(event, dict):
return event
if hasattr(event, "model_dump"):
return event.model_dump(by_alias=True, exclude_none=True)
raw = getattr(event, "raw", None)
if isinstance(raw, dict):
return raw
raise TypeError(f"Unsupported event type: {type(event).__name__}")
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
"""Conftest for golden tests — ensures parent test dir is importable."""
import sys
from pathlib import Path
def pytest_configure() -> None:
"""Ensure parent test directory is on sys.path for helper module imports."""
parent_test_dir = str(Path(__file__).resolve().parent.parent)
if parent_test_dir not in sys.path:
sys.path.insert(0, parent_test_dir)
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the basic agentic chat scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
BASIC_PAYLOAD: dict[str, Any] = {
"thread_id": "thread-chat",
"run_id": "run-chat",
"messages": [{"role": "user", "content": "Hello"}],
}
def _text_update(text: str) -> AgentResponseUpdate:
return AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant")
def _snapshot_role(msg: Any) -> str:
"""Extract role string from a snapshot message (Pydantic model or dict)."""
role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
if role is None:
return ""
return str(getattr(role, "value", role))
def _snapshot_content(msg: Any) -> str:
"""Extract content string from a snapshot message."""
content = getattr(msg, "content", None) or (msg.get("content") if isinstance(msg, dict) else "")
return str(content) if content else ""
# ── Golden stream tests ──
async def test_basic_chat_golden_event_sequence() -> None:
"""Assert the exact event type sequence for a single text response."""
agent = _build_agent([_text_update("Hi there!")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_strict_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
async def test_basic_chat_bookends() -> None:
"""RUN_STARTED is first, RUN_FINISHED is last."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_bookends()
async def test_basic_chat_text_messages_balanced() -> None:
"""Every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_text_messages_balanced()
async def test_basic_chat_no_errors() -> None:
"""No RUN_ERROR events in a normal flow."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_no_run_error()
async def test_basic_chat_message_id_consistency() -> None:
"""All text events reference the same message_id."""
agent = _build_agent([_text_update("reply")])
stream = await _run(agent, BASIC_PAYLOAD)
start = stream.first("TEXT_MESSAGE_START")
content = stream.first("TEXT_MESSAGE_CONTENT")
end = stream.first("TEXT_MESSAGE_END")
assert start.message_id == content.message_id == end.message_id
async def test_multi_chunk_text_golden_sequence() -> None:
"""Streaming multiple chunks produces START + multiple CONTENT + END."""
agent = _build_agent([_text_update("Hello "), _text_update("world!")])
stream = await _run(agent, BASIC_PAYLOAD)
stream.assert_strict_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
stream.assert_text_messages_balanced()
stream.assert_message_ids_consistent()
async def test_messages_snapshot_contains_assistant_reply() -> None:
"""MessagesSnapshotEvent includes the assistant's accumulated text."""
agent = _build_agent([_text_update("Hello there")])
stream = await _run(agent, BASIC_PAYLOAD)
snapshot = stream.messages_snapshot()
assistant_msgs = [m for m in snapshot if _snapshot_role(m) == "assistant"]
assert assistant_msgs, "No assistant message in snapshot"
assert any("Hello there" in _snapshot_content(m) for m in assistant_msgs)
async def test_empty_messages_produces_start_and_finish() -> None:
"""Empty message list still produces RUN_STARTED and RUN_FINISHED."""
agent = _build_agent([_text_update("reply")])
payload = {"thread_id": "t1", "run_id": "r1", "messages": []}
stream = await _run(agent, payload)
stream.assert_bookends()
assert "TEXT_MESSAGE_START" not in stream.types()
@@ -0,0 +1,236 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the backend (server-side) tools scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-tools",
"run_id": "run-tools",
"messages": [{"role": "user", "content": "What's the weather?"}],
}
# ── Golden stream tests ──
async def test_tool_call_lifecycle_golden_sequence() -> None:
"""Assert the full event sequence for a tool call → result → text response."""
updates = [
# LLM calls the tool
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
# Tool result comes back
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
role="assistant",
),
# LLM responds with text
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F and sunny in SF!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"TEXT_MESSAGE_START", # Synthetic start for tool-only message
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"TEXT_MESSAGE_END", # End of synthetic message
"TEXT_MESSAGE_START", # New message for text response
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"MESSAGES_SNAPSHOT",
"RUN_FINISHED",
]
)
async def test_tool_calls_balanced() -> None:
"""Every TOOL_CALL_START has a matching TOOL_CALL_END."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
async def test_text_messages_balanced_with_tools() -> None:
"""Text messages are properly balanced even around tool calls."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 72°F!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
async def test_tool_call_id_matches_result() -> None:
"""TOOL_CALL_START and TOOL_CALL_RESULT reference the same tool_call_id."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
start = stream.first("TOOL_CALL_START")
result = stream.first("TOOL_CALL_RESULT")
assert start.tool_call_id == result.tool_call_id == "call-1"
async def test_tool_result_content_preserved() -> None:
"""TOOL_CALL_RESULT event carries the tool's result content."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
result = stream.first("TOOL_CALL_RESULT")
assert result.content == "72°F and sunny"
async def test_no_run_error_on_tool_flow() -> None:
"""Tool call flow doesn't produce RUN_ERROR."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_no_run_error()
stream.assert_bookends()
async def test_multiple_sequential_tool_calls() -> None:
"""Multiple sequential tool calls each produce balanced START/END pairs."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="tool_a", call_id="call-a", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-a", result="result-a")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call(name="tool_b", call_id="call-b", arguments="{}")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-b", result="result-b")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="Done!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
stream.assert_bookends()
# Both tool calls should appear
starts = stream.get("TOOL_CALL_START")
assert len(starts) == 2
assert {s.tool_call_name for s in starts} == {"tool_a", "tool_b"}
async def test_messages_snapshot_includes_tool_calls() -> None:
"""MessagesSnapshotEvent includes tool call and result messages."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city":"SF"}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's warm!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_has_type("MESSAGES_SNAPSHOT")
snapshot = stream.messages_snapshot()
# Should have: user message, assistant with tool_calls, tool result, assistant text
assert len(snapshot) >= 3
@@ -0,0 +1,364 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the deterministic tool-driven state scenario.
Covers issue https://github.com/microsoft/agent-framework/issues/3167 — a tool
returning :func:`agent_framework_ag_ui.state_update` must push a deterministic
``StateSnapshotEvent`` derived from its actual return value, orthogonal to the
optimistic ``predict_state_config`` path. These golden tests pin the user-visible
event stream so additive changes cannot silently regress it.
"""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent, state_update
STATE_SCHEMA = {
"weather": {"type": "object", "description": "Last fetched weather"},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
kwargs.setdefault("state_schema", STATE_SCHEMA)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-det-state",
"run_id": "run-det-state",
"messages": [{"role": "user", "content": "What's the weather in SF?"}],
"state": {"weather": {}},
}
def _tool_call(call_id: str, name: str, arguments: str) -> AgentResponseUpdate:
return AgentResponseUpdate(
contents=[Content.from_function_call(name=name, call_id=call_id, arguments=arguments)],
role="assistant",
)
def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> AgentResponseUpdate:
"""Build a function_result update whose inner item carries a state marker.
This mirrors what the core framework produces when a real ``@tool`` returns
:func:`state_update`: ``parse_result`` keeps the ``Content`` as-is, and
``Content.from_function_result`` preserves its ``additional_properties``
inside ``items``.
"""
return AgentResponseUpdate(
contents=[
Content.from_function_result(
call_id=call_id,
result=[state_update(text=text, state=state)],
)
],
role="assistant",
)
def _tool_result_with_display(call_id: str, text: str, tool_result: Any, **kwargs: Any) -> AgentResponseUpdate:
"""Build a function_result update carrying an optional UI display marker."""
return AgentResponseUpdate(
contents=[
Content.from_function_result(
call_id=call_id,
result=[state_update(text=text, tool_result=tool_result, **kwargs)],
)
],
role="assistant",
)
# ── Golden stream tests ──
async def test_deterministic_state_emits_snapshot_after_tool_result() -> None:
"""The happy path: STATE_SNAPSHOT follows TOOL_CALL_RESULT in order."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather in SF: 14°C foggy",
state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}},
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 14°C and foggy in SF.")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
# Ordered subsequence: the deterministic STATE_SNAPSHOT must follow the
# TOOL_CALL_RESULT. This is the central contract for #3167.
stream.assert_ordered_types(
[
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"STATE_SNAPSHOT",
"RUN_FINISHED",
]
)
# The final STATE_SNAPSHOT must carry the tool-driven state.
snapshot = stream.snapshot()
assert snapshot["weather"] == {"city": "SF", "temp": 14, "conditions": "foggy"}
async def test_deterministic_state_does_not_fire_for_plain_tool_result() -> None:
"""Regression guard: tools returning plain strings must NOT emit a new STATE_SNAPSHOT.
The initial STATE_SNAPSHOT fires once from the schema + initial payload
state. A plain (non-state_update) tool result must not add another one.
"""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="14°C foggy")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 14°C and foggy.")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
snapshots = stream.get("STATE_SNAPSHOT")
# Only the initial snapshot (from state_schema + payload state) should exist.
# No deterministic snapshot should have been added by the plain tool result.
assert len(snapshots) == 1, (
f"Expected exactly 1 STATE_SNAPSHOT (initial only) for plain tool result; "
f"got {len(snapshots)}. Snapshots: {[s.snapshot for s in snapshots]}"
)
async def test_deterministic_state_merges_into_initial_state() -> None:
"""The tool-driven snapshot must merge into, not replace, pre-existing state keys."""
payload = dict(PAYLOAD)
payload["state"] = {"weather": {}, "user_preferences": {"unit": "C"}}
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather: 14°C",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(updates, state_schema={**STATE_SCHEMA, "user_preferences": {"type": "object"}})
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_no_run_error()
final_snapshot = stream.snapshot()
assert final_snapshot["weather"] == {"city": "SF", "temp": 14}
assert final_snapshot["user_preferences"] == {"unit": "C"}, (
"Pre-existing state keys must survive the deterministic merge"
)
async def test_deterministic_state_llm_visible_text_is_clean() -> None:
"""The LLM-visible TOOL_CALL_RESULT content must not leak the state marker key."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather in SF: 14°C foggy",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
result = stream.first("TOOL_CALL_RESULT")
assert result.content == "Weather in SF: 14°C foggy"
# The marker key must never appear in the content sent back to the LLM.
assert "__ag_ui_tool_result_state__" not in result.content
assert "weather" not in result.content # not as a raw state dump
async def test_deterministic_state_multiple_tools_merge_in_order() -> None:
"""Two state-updating tools in one run merge in order; later wins on key collisions."""
updates = [
_tool_call("call-a", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-a",
text="First result",
state={"weather": {"city": "SF", "temp": 14}, "source": "primary"},
),
_tool_call("call-b", "get_weather_refined", '{"city": "SF"}'),
_tool_result_with_state(
"call-b",
text="Refined result",
state={"source": "refined"},
),
AgentResponseUpdate(
contents=[Content.from_text(text="Here you go.")],
role="assistant",
),
]
agent = _build_agent(
updates,
state_schema={**STATE_SCHEMA, "source": {"type": "string"}},
)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_tool_calls_balanced()
stream.assert_no_run_error()
# Two tool-driven snapshots emitted (one per tool) plus the initial snapshot.
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) >= 2, f"Expected at least 2 STATE_SNAPSHOTs; got {len(snapshots)}"
final = stream.snapshot()
assert final["weather"] == {"city": "SF", "temp": 14}
# Later tool must override earlier tool on the shared key.
assert final["source"] == "refined"
async def test_deterministic_state_coexists_with_predict_state_config() -> None:
"""Predictive state and deterministic state must coexist without clobbering each other."""
predict_config = {
"draft": {
"tool": "write_draft",
"tool_argument": "body",
}
}
updates = [
# Predictive tool: its argument "body" populates state.draft optimistically.
_tool_call("call-1", "write_draft", '{"body": "Hello world"}'),
# Then a deterministic tool result landing a different key.
_tool_result_with_state(
"call-1",
text="Draft saved",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(
updates,
state_schema={**STATE_SCHEMA, "draft": {"type": "string"}},
predict_state_config=predict_config,
require_confirmation=False,
)
payload = dict(PAYLOAD)
payload["state"] = {"weather": {}, "draft": ""}
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
# The final observed state must contain both the deterministic and predictive contributions.
final = stream.snapshot()
assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}"
async def test_tool_result_display_payload_reaches_ui_event_only() -> None:
"""Rich display payload overrides TOOL_CALL_RESULT without leaking marker keys."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_display(
"call-1",
text="Weather in SF: 14°C foggy",
tool_result={"city": "SF", "temp": 14, "conditions": "foggy"},
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
result = stream.first("TOOL_CALL_RESULT")
assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}'
assert "__ag_ui_tool_result_display__" not in result.content
assert "__ag_ui_tool_result_state__" not in result.content
async def test_tool_result_display_falls_back_to_text_when_unset() -> None:
"""Without a display marker, the UI event keeps the existing text content."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather in SF: 14°C foggy",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
result = stream.first("TOOL_CALL_RESULT")
assert result.content == "Weather in SF: 14°C foggy"
assert "__ag_ui_tool_result_display__" not in result.content
assert "__ag_ui_tool_result_state__" not in result.content
async def test_tool_result_display_coexists_with_state_snapshot() -> None:
"""Display and durable state markers produce one deterministic state snapshot."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_display(
"call-1",
text="Weather in SF: 14°C foggy",
tool_result={"city": "SF", "temp": 14, "conditions": "foggy"},
state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}},
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
stream.assert_ordered_types(["TOOL_CALL_RESULT", "STATE_SNAPSHOT", "RUN_FINISHED"])
result = stream.first("TOOL_CALL_RESULT")
assert result.content == '{"city": "SF", "temp": 14, "conditions": "foggy"}'
result_idx = stream.events.index(result)
deterministic_snapshots = [
event
for event in stream.events[result_idx + 1 :]
if getattr(getattr(event, "type", None), "value", getattr(event, "type", None)) == "STATE_SNAPSHOT"
]
assert len(deterministic_snapshots) == 1
assert deterministic_snapshots[0].snapshot["weather"] == {
"city": "SF",
"temp": 14,
"conditions": "foggy",
}
assert "__ag_ui_tool_result_display__" not in str(deterministic_snapshots[0].snapshot)
assert "__ag_ui_tool_result_state__" not in str(deterministic_snapshots[0].snapshot)
@@ -0,0 +1,90 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the generative UI (workflow-as-agent) scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import WorkflowBuilder, WorkflowContext, executor
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkWorkflow
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in wrapper.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-gen-ui-agent",
"run_id": "run-gen-ui-agent",
"messages": [{"role": "user", "content": "Generate a UI"}],
}
# ── Golden stream tests ──
async def test_workflow_agent_golden_sequence() -> None:
"""Workflow-as-agent: emits step events and text content."""
@executor(id="generator")
async def generator(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("Here is your generated UI content!")
workflow = WorkflowBuilder(start_executor=generator).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_text_messages_balanced()
# Should have step events for the executor
stream.assert_has_type("STEP_STARTED")
stream.assert_has_type("STEP_FINISHED")
# Should have text message content
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
async def test_workflow_agent_step_names_match() -> None:
"""Step started/finished events reference the executor name."""
@executor(id="my_executor")
async def my_executor(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("Done!")
workflow = WorkflowBuilder(start_executor=my_executor).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "my_executor"]
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "my_executor"]
assert started, "Expected STEP_STARTED for 'my_executor'"
assert finished, "Expected STEP_FINISHED for 'my_executor'"
async def test_workflow_agent_ordered_events() -> None:
"""Workflow events follow expected ordering: RUN_STARTED → STEP_STARTED → content → STEP_FINISHED → RUN_FINISHED."""
@executor(id="my_step")
async def my_step(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("Generated content")
workflow = WorkflowBuilder(start_executor=my_step).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"STEP_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"STEP_FINISHED",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
]
)
@@ -0,0 +1,135 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the client-side (declaration-only) tools scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-gen-ui-tool",
"run_id": "run-gen-ui-tool",
"messages": [{"role": "user", "content": "Show me a chart"}],
"tools": [
{
"type": "function",
"function": {
"name": "render_chart",
"description": "Render a chart in the UI",
"parameters": {
"type": "object",
"properties": {"data": {"type": "array"}},
},
},
}
],
}
# ── Golden stream tests ──
async def test_declaration_only_tool_golden_sequence() -> None:
"""Declaration-only tool: TOOL_CALL_START/ARGS emitted, TOOL_CALL_END at stream end."""
# The LLM calls a client-side tool (no server-side execution)
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# Tool call start and args should be present
stream.assert_has_type("TOOL_CALL_START")
stream.assert_has_type("TOOL_CALL_ARGS")
# TOOL_CALL_END should be emitted (via get_pending_without_end)
stream.assert_has_type("TOOL_CALL_END")
stream.assert_tool_calls_balanced()
async def test_declaration_only_tool_no_tool_call_result() -> None:
"""Declaration-only tools should NOT produce TOOL_CALL_RESULT events."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
assert "TOOL_CALL_RESULT" not in stream.types(), "Declaration-only tools should not have TOOL_CALL_RESULT"
async def test_declaration_only_tool_text_messages_balanced() -> None:
"""Text messages remain balanced even with declaration-only tools."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
async def test_declaration_only_tool_messages_snapshot() -> None:
"""MessagesSnapshotEvent includes the tool call for declaration-only tools."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="render_chart",
call_id="call-chart",
arguments='{"data": [1, 2, 3]}',
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_has_type("MESSAGES_SNAPSHOT")
@@ -0,0 +1,194 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the HITL (human-in-the-loop) approval scenario."""
from __future__ import annotations
import json
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent
PREDICT_CONFIG = {
"tasks": {
"tool": "generate_task_steps",
"tool_argument": "steps",
}
}
STATE_SCHEMA = {
"tasks": {"type": "array", "items": {"type": "object"}},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(
agent=stub,
state_schema=STATE_SCHEMA,
predict_state_config=PREDICT_CONFIG,
require_confirmation=True,
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
STEPS = [
{"description": "Step 1: Plan", "status": "enabled"},
{"description": "Step 2: Execute", "status": "enabled"},
]
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-hitl",
"run_id": "run-hitl",
"messages": [{"role": "user", "content": "Plan my tasks"}],
"state": {"tasks": []},
}
# ── Turn 1: Tool call → confirm_changes → interrupt ──
async def test_hitl_turn1_golden_sequence() -> None:
"""Turn 1 emits tool call, confirm_changes, and finishes with interrupt."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# Should have: tool call start/args/end for the primary tool,
# then TOOL_CALL_END, STATE_SNAPSHOT, confirm_changes cycle
stream.assert_bookends()
stream.assert_no_run_error()
# confirm_changes tool call should be present
tool_starts = stream.get("TOOL_CALL_START")
tool_names = [getattr(s, "tool_call_name", None) for s in tool_starts]
assert "generate_task_steps" in tool_names
assert "confirm_changes" in tool_names
# RUN_FINISHED should have interrupt metadata
interrupt = stream.run_finished_interrupts()
assert len(interrupt) > 0
async def test_hitl_turn1_tool_calls_balanced() -> None:
"""All tool calls in turn 1 (primary + confirm_changes) are balanced."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_tool_calls_balanced()
async def test_hitl_turn1_text_messages_balanced() -> None:
"""Text messages are balanced even in the approval flow."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="generate_task_steps",
call_id="call-steps",
arguments=json.dumps({"steps": STEPS}),
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_text_messages_balanced()
# ── Turn 2: Resume with approval → confirmation message → no interrupt ──
async def test_hitl_turn2_resume_with_approval() -> None:
"""Resuming with confirm_changes result emits confirmation text and finishes cleanly."""
# Turn 2: user sends confirm_changes result as resume
# The agent wrapper sees a confirm_changes response and emits a confirmation message
confirm_result = json.dumps(
{
"accepted": True,
"steps": STEPS,
}
)
# Build payload with resume containing the approval
# For confirm_changes, the messages should include the tool result
payload: dict[str, Any] = {
"thread_id": "thread-hitl",
"run_id": "run-hitl-2",
"messages": [
{"role": "user", "content": "Plan my tasks"},
{
"role": "assistant",
"tool_calls": [
{
"id": "confirm-id-1",
"type": "function",
"function": {"name": "confirm_changes", "arguments": json.dumps({"steps": STEPS})},
}
],
},
{
"role": "tool",
"toolCallId": "confirm-id-1",
"content": confirm_result,
},
],
"state": {"tasks": []},
}
# In turn 2, the agent sees the confirm_changes result and emits a confirmation text
updates = [
AgentResponseUpdate(
contents=[Content.from_text(text="Tasks confirmed!")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_text_messages_balanced()
stream.assert_no_run_error()
# Should have text message content (the confirmation message)
text_events = stream.get("TEXT_MESSAGE_CONTENT")
assert text_events, "Expected confirmation text message"
# RUN_FINISHED should NOT have interrupt (approval completed)
finished = stream.last("RUN_FINISHED")
dumped = finished.model_dump(by_alias=True, exclude_none=True)
assert "outcome" not in dumped, f"Expected no interrupt after approval, got {dumped.get('outcome')}"
@@ -0,0 +1,130 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the predictive state scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkAgent
PREDICT_CONFIG = {
"document": {
"tool": "update_document",
"tool_argument": "content",
}
}
STATE_SCHEMA = {
"document": {"type": "string"},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
return AgentFrameworkAgent(
agent=stub,
state_schema=STATE_SCHEMA,
predict_state_config=PREDICT_CONFIG,
require_confirmation=False,
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-predict",
"run_id": "run-predict",
"messages": [{"role": "user", "content": "Write a document"}],
"state": {"document": ""},
}
# ── Golden stream tests ──
async def test_predictive_state_emits_deltas_during_tool_args() -> None:
"""STATE_DELTA events are emitted as tool arguments stream in."""
updates = [
AgentResponseUpdate(
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments="")],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "Hello')
],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments=' world"}')],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# PredictState custom event should be present
custom_events = stream.get("CUSTOM")
predict_events = [e for e in custom_events if getattr(e, "name", None) == "PredictState"]
assert predict_events, "Expected PredictState custom event"
# STATE_DELTA events should be emitted during tool arg streaming
assert "STATE_DELTA" in stream.types(), "Expected STATE_DELTA events during predictive streaming"
async def test_predictive_state_snapshot_after_tool_end() -> None:
"""STATE_SNAPSHOT is emitted when a predictive tool completes (no confirmation)."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(
name="update_document", call_id="call-1", arguments='{"content": "Final text"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
# Should have initial state snapshot + updated snapshot after tool completion
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) >= 1, "Expected at least one STATE_SNAPSHOT"
async def test_predictive_state_ordered_events() -> None:
"""Event ordering: RUN_STARTED → PredictState → STATE_SNAPSHOT → TOOL_CALL_* → STATE_SNAPSHOT → RUN_FINISHED."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "doc"}')
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_ordered_types(
[
"RUN_STARTED",
"CUSTOM", # PredictState
"STATE_SNAPSHOT", # Initial state
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"RUN_FINISHED",
]
)
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the shared state (structured output) scenario."""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from pydantic import BaseModel
from agent_framework_ag_ui import AgentFrameworkAgent
class RecipeState(BaseModel):
recipe_title: str = ""
ingredients: list[str] = []
message: str = ""
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(
updates=updates,
default_options={"tools": None, "response_format": RecipeState},
)
return AgentFrameworkAgent(
agent=stub,
state_schema={
"recipe_title": {"type": "string"},
"ingredients": {"type": "array", "items": {"type": "string"}},
},
**kwargs,
)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-state",
"run_id": "run-state",
"messages": [{"role": "user", "content": "Give me a pasta recipe"}],
"state": {"recipe_title": "", "ingredients": []},
}
# ── Golden stream tests ──
async def test_shared_state_emits_state_snapshot() -> None:
"""Structured output agent emits STATE_SNAPSHOT with parsed model fields."""
# The structured output agent gets a response that the framework parses as RecipeState
updates = [
AgentResponseUpdate(
contents=[
Content.from_text(
text='{"recipe_title": "Pasta Carbonara", "ingredients": ["pasta", "eggs", "cheese"], "message": "Here is your recipe!"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
# Should have STATE_SNAPSHOT with the initial state at minimum
stream.assert_has_type("STATE_SNAPSHOT")
async def test_shared_state_initial_snapshot_on_first_update() -> None:
"""When state_schema and state are provided, initial STATE_SNAPSHOT is emitted after RUN_STARTED."""
updates = [
AgentResponseUpdate(
contents=[Content.from_text(text='{"recipe_title": "Test", "ingredients": [], "message": "hi"}')],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# RUN_STARTED should be followed by STATE_SNAPSHOT (initial state)
stream.assert_ordered_types(["RUN_STARTED", "STATE_SNAPSHOT"])
async def test_shared_state_text_emitted_from_message_field() -> None:
"""Structured output's 'message' field is emitted as text message events."""
updates = [
AgentResponseUpdate(
contents=[
Content.from_text(
text='{"recipe_title": "Pasta", "ingredients": ["pasta"], "message": "Enjoy your pasta!"}'
)
],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
# Text should be emitted from the message field
text_contents = stream.get("TEXT_MESSAGE_CONTENT")
if text_contents:
combined = "".join(getattr(e, "delta", "") for e in text_contents)
assert "Enjoy your pasta!" in combined
@@ -0,0 +1,207 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the workflow HITL (subgraphs) scenario.
Extends the existing test_subgraphs_example_agent.py with EventStream assertions
on full event ordering, balancing, and interrupt structure.
"""
from __future__ import annotations
import json
from typing import Any
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
async def _run(agent: Any, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
# ── Turn 1: Initial request → flight interrupt ──
async def test_subgraphs_turn1_golden_bookends() -> None:
"""Turn 1 starts with RUN_STARTED and ends with RUN_FINISHED."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-1",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to San Francisco"}],
},
)
stream.assert_bookends()
async def test_subgraphs_turn1_no_errors() -> None:
"""Turn 1 completes without errors."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-2",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_no_run_error()
async def test_subgraphs_turn1_has_step_events() -> None:
"""Turn 1 emits STEP_STARTED and STEP_FINISHED for workflow executors."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-3",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_has_type("STEP_STARTED")
stream.assert_has_type("STEP_FINISHED")
async def test_subgraphs_turn1_interrupt_structure() -> None:
"""Turn 1 RUN_FINISHED carries flight interrupt with correct structure."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-4",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to SF"}],
},
)
interrupt = stream.run_finished_interrupts()
assert len(interrupt) > 0
interrupt_value = stream.interrupt_metadata_value(interrupt[0])
assert interrupt_value["agent"] == "flights"
assert len(interrupt_value["options"]) == 2
async def test_subgraphs_turn1_text_messages_balanced() -> None:
"""All text messages in turn 1 are properly balanced."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-5",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_text_messages_balanced()
async def test_subgraphs_turn1_ordered_flow() -> None:
"""Turn 1 event ordering: RUN_STARTED → STATE_SNAPSHOT → STEP_* → TOOL_CALL_* → RUN_FINISHED."""
agent = subgraphs_agent()
stream = await _run(
agent,
{
"thread_id": "thread-sub-golden-6",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip"}],
},
)
stream.assert_ordered_types(
[
"RUN_STARTED",
"STATE_SNAPSHOT",
"STEP_STARTED",
"RUN_FINISHED",
]
)
# ── Multi-turn: Flight selection → hotel interrupt → completion ──
async def test_subgraphs_full_flow_event_ordering() -> None:
"""Complete 3-turn flow maintains proper event ordering throughout."""
agent = subgraphs_agent()
thread_id = "thread-sub-golden-full"
# Turn 1
stream1 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-1",
"messages": [{"role": "user", "content": "Plan a trip to SF from Amsterdam"}],
},
)
stream1.assert_bookends()
stream1.assert_no_run_error()
# Extract flight interrupt
interrupt1 = stream1.run_finished_interrupts()[0]
# Turn 2: Select flight
stream2 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-2",
"resume": {
"interrupts": [
{
"id": interrupt1["id"],
"value": json.dumps(
{
"airline": "United",
"departure": "Amsterdam (AMS)",
"arrival": "San Francisco (SFO)",
"price": "$720",
"duration": "12h 15m",
}
),
}
]
},
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
# Should now have hotel interrupt
interrupt2 = stream2.run_finished_interrupts()
assert stream2.interrupt_metadata_value(interrupt2[0])["agent"] == "hotels"
# Turn 3: Select hotel
stream3 = await _run(
agent,
{
"thread_id": thread_id,
"run_id": "run-3",
"resume": {
"interrupts": [
{
"id": interrupt2[0]["id"],
"value": json.dumps(
{
"name": "The Ritz-Carlton",
"location": "Nob Hill",
"price_per_night": "$550/night",
"rating": "4.8 stars",
}
),
}
]
},
},
)
stream3.assert_bookends()
stream3.assert_no_run_error()
stream3.assert_text_messages_balanced()
# Final turn should not have interrupt
finished3 = stream3.last("RUN_FINISHED")
final_dump = finished3.model_dump(by_alias=True, exclude_none=True)
assert "outcome" not in final_dump, f"Expected no interrupt after completion, got {final_dump.get('outcome')}"
@@ -0,0 +1,945 @@
# Copyright (c) Microsoft. All rights reserved.
"""Comprehensive golden event-stream tests for AgentFrameworkWorkflow.
Covers the full matrix of workflow-specific AG-UI patterns:
- request_info → TOOL_CALL lifecycle and balancing
- Executor step events and activity snapshots
- Text output, dict output, BaseEvent passthrough, AgentResponse output
- Text deduplication across workflow outputs
- Workflow error handling → RUN_ERROR
- Multi-turn interrupt/resume round-trips
- Empty turns with pending requests
- Custom workflow events
- Text message draining on request_info and executor boundaries
"""
import json
from typing import Any, cast
from ag_ui.core import EventType, StateSnapshotEvent
from agent_framework import (
AgentResponse,
Content,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
handler,
response_handler,
)
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
from agent_framework_ag_ui import AgentFrameworkWorkflow
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in wrapper.run(payload)])
def _payload(
msg: str = "go",
*,
thread_id: str = "thread-wf",
run_id: str = "run-wf",
**extra: Any,
) -> dict[str, Any]:
return {"thread_id": thread_id, "run_id": run_id, "messages": [{"role": "user", "content": msg}], **extra}
# ──────────────────────────────────────────────────────────────────────
# 1. Basic workflow text output
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_text_output_golden_sequence() -> None:
"""Simple text output: RUN_STARTED → STEP_STARTED → TEXT_* → STEP_FINISHED → TEXT_MESSAGE_END → RUN_FINISHED."""
@executor(id="greeter")
async def greeter(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("Hello from workflow!")
workflow = WorkflowBuilder(start_executor=greeter).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_text_messages_balanced()
stream.assert_has_type("TEXT_MESSAGE_START")
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
stream.assert_has_type("TEXT_MESSAGE_END")
# Verify actual content
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert "Hello from workflow!" in deltas
async def test_workflow_text_output_message_id_consistency() -> None:
"""All text events for a single output share the same message_id."""
@executor(id="echo")
async def echo(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("echo reply")
workflow = WorkflowBuilder(start_executor=echo).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_message_ids_consistent()
# ──────────────────────────────────────────────────────────────────────
# 2. Executor step events and activity snapshots
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_executor_lifecycle_events() -> None:
"""Executor invocation produces STEP_STARTED, ACTIVITY_SNAPSHOT, STEP_FINISHED."""
@executor(id="worker")
async def worker(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("done")
workflow = WorkflowBuilder(start_executor=worker).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
# Step events with executor ID
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "worker"]
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "worker"]
assert started, "Expected STEP_STARTED for 'worker'"
assert finished, "Expected STEP_FINISHED for 'worker'"
# Activity snapshots
activities = stream.get("ACTIVITY_SNAPSHOT")
assert activities, "Expected ACTIVITY_SNAPSHOT events"
# Check one of them has executor payload
executor_activities = [a for a in activities if getattr(a, "activity_type", None) == "executor"]
assert executor_activities, "Expected executor-type activity snapshots"
async def test_workflow_executor_step_ordering() -> None:
"""STEP_STARTED comes before content, STEP_FINISHED comes after."""
@executor(id="orderer")
async def orderer(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("ordered output")
workflow = WorkflowBuilder(start_executor=orderer).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_ordered_types(
[
"RUN_STARTED",
"STEP_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"STEP_FINISHED",
"RUN_FINISHED",
]
)
# ──────────────────────────────────────────────────────────────────────
# 3. Dict output → CUSTOM workflow_output
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_dict_output_maps_to_custom_event() -> None:
"""Non-chat dict output is emitted as CUSTOM workflow_output event."""
@executor(id="structured")
async def structured(message: Any, ctx: WorkflowContext[Any, dict[str, int]]) -> None:
await ctx.yield_output({"count": 42, "status": 1})
workflow = WorkflowBuilder(start_executor=structured).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
customs = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "workflow_output"]
assert len(customs) == 1
assert customs[0].value == {"count": 42, "status": 1}
# Should NOT have TEXT_MESSAGE events for dict output
assert "TEXT_MESSAGE_CONTENT" not in stream.types()
# ──────────────────────────────────────────────────────────────────────
# 4. BaseEvent passthrough
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_base_event_passthrough() -> None:
"""AG-UI BaseEvent outputs are yielded directly, not wrapped."""
@executor(id="stateful")
async def stateful(message: Any, ctx: WorkflowContext[Any, StateSnapshotEvent]) -> None:
await ctx.yield_output(StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"active_agent": "flights"}))
workflow = WorkflowBuilder(start_executor=stateful).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) == 1
assert snapshots[0].snapshot["active_agent"] == "flights"
# ──────────────────────────────────────────────────────────────────────
# 5. AgentResponse output (conversation payload)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_agent_response_output_extracts_latest_assistant() -> None:
"""AgentResponse output uses only the latest assistant message, not full history."""
@executor(id="responder")
async def responder(message: Any, ctx: WorkflowContext[Any, AgentResponse]) -> None:
response = AgentResponse(
messages=[
Message(role="user", contents=[Content.from_text("My order is damaged")]),
Message(role="assistant", contents=[Content.from_text("I'll process your replacement.")]),
]
)
await ctx.yield_output(response)
workflow = WorkflowBuilder(start_executor=responder).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["I'll process your replacement."]
# ──────────────────────────────────────────────────────────────────────
# 6. Custom workflow events
# ──────────────────────────────────────────────────────────────────────
class ProgressEvent(WorkflowEvent):
"""Custom workflow event for testing CUSTOM event mapping."""
def __init__(self, progress: int) -> None:
super().__init__(cast(Any, "custom_progress"), data={"progress": progress})
async def test_workflow_custom_events() -> None:
"""Custom workflow events are mapped to CUSTOM AG-UI events."""
@executor(id="progress_tracker")
async def progress_tracker(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.add_event(ProgressEvent(25))
await ctx.yield_output("In progress...")
await ctx.add_event(ProgressEvent(100))
workflow = WorkflowBuilder(start_executor=progress_tracker).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
progress_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "custom_progress"]
assert len(progress_events) == 2
assert progress_events[0].value == {"progress": 25}
assert progress_events[1].value == {"progress": 100}
# ──────────────────────────────────────────────────────────────────────
# 7. request_info → TOOL_CALL lifecycle
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_request_info_tool_call_lifecycle() -> None:
"""request_info emits TOOL_CALL_START/ARGS/END cycle plus CUSTOM request_info."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info("Need approval", str, request_id="req-1")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
# Tool call lifecycle
stream.assert_ordered_types(
[
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"CUSTOM", # request_info
"RUN_FINISHED",
]
)
# Verify tool call details
start = stream.first("TOOL_CALL_START")
assert start.tool_call_id == "req-1"
assert start.tool_call_name == "request_info"
# TOOL_CALL_ARGS should contain the request payload
args = stream.first("TOOL_CALL_ARGS")
assert args.tool_call_id == "req-1"
parsed_args = json.loads(args.delta)
assert parsed_args["request_id"] == "req-1"
# Tool calls should be balanced
stream.assert_tool_calls_balanced()
async def test_workflow_request_info_interrupt_in_run_finished() -> None:
"""request_info populates RUN_FINISHED.outcome.interrupts with the request metadata."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
dict,
request_id="flights-choice",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
interrupt = stream.run_finished_interrupts()
assert len(interrupt) == 1
assert interrupt[0]["id"] == "flights-choice"
assert stream.interrupt_metadata_value(interrupt[0])["agent"] == "flights"
async def test_workflow_request_info_emits_interrupt_card_event() -> None:
"""request_info with dict data emits a WorkflowInterruptEvent custom event."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Pick one", "options": ["A", "B"]},
dict,
request_id="pick-1",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
interrupt_cards = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "WorkflowInterruptEvent"]
assert interrupt_cards, "Expected WorkflowInterruptEvent custom event"
# ──────────────────────────────────────────────────────────────────────
# 8. Text message draining on request_info boundary
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_text_drained_before_request_info() -> None:
"""Open text message is closed (TEXT_MESSAGE_END) before request_info tool calls begin."""
@executor(id="text_then_request")
async def text_then_request(message: Any, ctx: WorkflowContext) -> None:
await ctx.yield_output("Please confirm this action.") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
await ctx.request_info("Need approval", str, request_id="approval-1")
workflow = WorkflowBuilder(start_executor=text_then_request).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
stream.assert_tool_calls_balanced()
# TEXT_MESSAGE_END must appear before TOOL_CALL_START
types = stream.types()
text_end_idx = types.index("TEXT_MESSAGE_END")
tool_start_idx = types.index("TOOL_CALL_START")
assert text_end_idx < tool_start_idx, (
f"TEXT_MESSAGE_END (idx={text_end_idx}) must come before TOOL_CALL_START (idx={tool_start_idx})"
)
# ──────────────────────────────────────────────────────────────────────
# 9. Text deduplication
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_skips_duplicate_text_from_snapshot() -> None:
"""Duplicate text from AgentResponse snapshot is not re-emitted."""
@executor(id="deduper")
async def deduper(message: Any, ctx: WorkflowContext[Any, Any]) -> None:
text = "Order processed successfully."
await ctx.yield_output(text)
# Snapshot repeats the same text
await ctx.yield_output(
AgentResponse(
messages=[
Message(role="user", contents=[Content.from_text("process order")]),
Message(role="assistant", contents=[Content.from_text(text)]),
]
)
)
workflow = WorkflowBuilder(start_executor=deduper).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
# Text should appear only once
assert deltas == ["Order processed successfully."]
async def test_workflow_skips_consecutive_duplicate_outputs() -> None:
"""Consecutive identical text outputs are deduplicated."""
@executor(id="repeater")
async def repeater(message: Any, ctx: WorkflowContext[Any, Any]) -> None:
text = "Done!"
await ctx.yield_output(text)
await ctx.yield_output(text)
workflow = WorkflowBuilder(start_executor=repeater).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["Done!"]
async def test_workflow_emits_distinct_consecutive_outputs() -> None:
"""Distinct text outputs are all emitted, not incorrectly deduplicated."""
@executor(id="multisayer")
async def multisayer(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("First part. ")
await ctx.yield_output("Second part.")
workflow = WorkflowBuilder(start_executor=multisayer).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_text_messages_balanced()
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
assert deltas == ["First part. ", "Second part."]
# ──────────────────────────────────────────────────────────────────────
# 10. Workflow error handling → RUN_ERROR
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_error_emits_run_error_event() -> None:
"""Exceptions during workflow streaming produce RUN_ERROR events."""
class FailingWorkflow:
def run(self, **kwargs: Any):
async def _stream():
raise RuntimeError("workflow exploded")
yield # pragma: no cover
return _stream()
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
stream = await _run(wrapper, _payload())
# Should still have RUN_STARTED
stream.assert_has_type("RUN_STARTED")
# Should have RUN_ERROR
stream.assert_has_type("RUN_ERROR")
error = stream.first("RUN_ERROR")
assert "workflow exploded" in error.message
async def test_workflow_error_preserves_bookend_structure() -> None:
"""Even on error, RUN_STARTED is the first event."""
class FailingWorkflow:
def run(self, **kwargs: Any):
async def _stream():
raise ValueError("bad input")
yield # pragma: no cover
return _stream()
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
stream = await _run(wrapper, _payload())
types = stream.types()
assert types[0] == "RUN_STARTED"
assert "RUN_ERROR" in types
# ──────────────────────────────────────────────────────────────────────
# 11. Multi-turn request_info interrupt/resume
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_interrupt_resume_round_trip() -> None:
"""Turn 1: request_info → interrupt. Turn 2: resume → completion."""
class RequesterExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="requester")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info("Choose an option", str, request_id="choice-1")
@response_handler
async def handle_choice(self, original: str, response: str, ctx: WorkflowContext) -> None:
await ctx.yield_output(f"You chose: {response}") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
workflow = WorkflowBuilder(start_executor=RequesterExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
stream1 = await _run(wrapper, _payload(thread_id="thread-resume", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_no_run_error()
stream1.assert_tool_calls_balanced()
interrupt1 = stream1.run_finished_interrupts()
assert interrupt1, "Expected interrupt"
assert interrupt1[0]["id"] == "choice-1"
# Turn 2: resume
stream2 = await _run(
wrapper,
{
"thread_id": "thread-resume",
"run_id": "run-2",
"messages": [],
"resume": {"interrupts": [{"id": "choice-1", "value": "Option A"}]},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
# Should have the response text
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("Option A" in d for d in deltas), f"Expected 'Option A' in deltas: {deltas}"
# No interrupt after resume
finished2 = stream2.last("RUN_FINISHED")
dumped2 = finished2.model_dump(by_alias=True, exclude_none=True)
assert "outcome" not in dumped2
async def test_workflow_forwarded_props_resume() -> None:
"""forwarded_props.command.resume should resume with canonical resume entries."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"options": [{"name": "A"}]}, dict, request_id="pick")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-fwd", run_id="run-1"))
# Turn 2 via forwarded_props
stream2 = await _run(
wrapper,
{
"thread_id": "thread-fwd",
"run_id": "run-2",
"messages": [],
"forwarded_props": {
"command": {"resume": [{"interruptId": "pick", "status": "resolved", "payload": {"name": "A"}}]}
},
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
finished = stream2.last("RUN_FINISHED")
assert "outcome" not in finished.model_dump(by_alias=True, exclude_none=True)
# ──────────────────────────────────────────────────────────────────────
# 12. Empty turns with pending requests
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_empty_turn_with_pending_request_emits_run_error() -> None:
"""An empty turn with a pending request must provide canonical resume entries."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"prompt": "choose"}, dict, request_id="pick-one")
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1: trigger the request
await _run(wrapper, _payload(thread_id="thread-empty", run_id="run-1"))
# Turn 2: empty messages, no resume
stream2 = await _run(
wrapper,
{
"thread_id": "thread-empty",
"run_id": "run-2",
"messages": [],
},
)
stream2.assert_has_type("RUN_STARTED")
run_error = stream2.last("RUN_ERROR")
assert run_error.code == "WORKFLOW_RESUME_REQUIRED"
async def test_workflow_empty_turn_no_pending_requests() -> None:
"""Empty turn with no pending requests produces clean bookends."""
@executor(id="noop")
async def noop(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output("done")
workflow = WorkflowBuilder(start_executor=noop).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Run once to completion
await _run(wrapper, _payload(thread_id="thread-empty-clean", run_id="run-1"))
# Empty turn
stream2 = await _run(
wrapper,
{
"thread_id": "thread-empty-clean",
"run_id": "run-2",
"messages": [],
},
)
stream2.assert_bookends()
stream2.assert_no_run_error()
# ──────────────────────────────────────────────────────────────────────
# 13. Usage content as CUSTOM event
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_usage_output_maps_to_custom_event() -> None:
"""Usage Content outputs are surfaced as custom usage events."""
@executor(id="usage_reporter")
async def usage_reporter(message: Any, ctx: WorkflowContext[Any, Content]) -> None:
await ctx.yield_output(
Content.from_usage({"input_token_count": 100, "output_token_count": 50, "total_token_count": 150})
)
workflow = WorkflowBuilder(start_executor=usage_reporter).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
stream = await _run(wrapper, _payload())
stream.assert_bookends()
stream.assert_no_run_error()
usage_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "usage"]
assert len(usage_events) == 1
assert usage_events[0].value["input_token_count"] == 100
assert usage_events[0].value["total_token_count"] == 150
# ──────────────────────────────────────────────────────────────────────
# 14. Approval flow (Content-based request_info)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_approval_flow_round_trip() -> None:
"""function_approval_request via request_info, then resume with approval response."""
class ApprovalExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="approval_exec")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
function_call = Content.from_function_call(
call_id="refund-call",
name="submit_refund",
arguments={"order_id": "12345", "amount": "$89.99"},
)
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
await ctx.request_info(approval_request, Content, request_id="approval-1")
@response_handler
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
status = "approved" if bool(response.approved) else "rejected"
await ctx.yield_output(f"Refund {status}.") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1: request approval
stream1 = await _run(wrapper, _payload(thread_id="thread-approval", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_no_run_error()
interrupt1 = stream1.run_finished_interrupts()
assert interrupt1, "Expected approval interrupt"
interrupt_value = stream1.interrupt_metadata_value(interrupt1[0])
# Turn 2: approve
stream2 = await _run(
wrapper,
{
"thread_id": "thread-approval",
"run_id": "run-2",
"messages": [],
"resume": {
"interrupts": [
{
"id": "approval-1",
"value": {
"type": "function_approval_response",
"approved": True,
"id": interrupt_value.get("id", "approval-1"),
"function_call": interrupt_value.get("function_call"),
},
}
]
},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("approved" in d for d in deltas)
# No more interrupt
finished2 = stream2.last("RUN_FINISHED")
assert "outcome" not in finished2.model_dump(by_alias=True, exclude_none=True)
# ──────────────────────────────────────────────────────────────────────
# 15. Message list request/response coercion
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_message_list_resume() -> None:
"""Resume with list[Message] payload coerces correctly into workflow response."""
class MessageRequestExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="msg_request")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info({"prompt": "Need follow-up"}, list[Message], request_id="handoff")
@response_handler
async def handle_input(self, original: dict, response: list[Message], ctx: WorkflowContext) -> None:
user_text = response[0].text if response else ""
await ctx.yield_output(f"Got: {user_text}") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
workflow = WorkflowBuilder(start_executor=MessageRequestExecutor()).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-msg", run_id="run-1"))
# Turn 2: resume with message list
stream2 = await _run(
wrapper,
{
"thread_id": "thread-msg",
"run_id": "run-2",
"messages": [],
"resume": {
"interrupts": [
{
"id": "handoff",
"value": [
{"role": "user", "contents": [{"type": "text", "text": "Ship a replacement"}]},
],
}
]
},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_no_run_error()
stream2.assert_text_messages_balanced()
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
assert any("replacement" in d for d in deltas)
# ──────────────────────────────────────────────────────────────────────
# 16. Plain text follow-up does NOT infer interrupt response
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_plain_text_does_not_resume_pending_dict_request() -> None:
"""Plain text user follow-up should fail instead of being coerced into a dict response."""
@executor(id="requester")
async def requester(message: Any, ctx: WorkflowContext) -> None:
await ctx.request_info(
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
dict,
request_id="flights-choice",
)
workflow = WorkflowBuilder(start_executor=requester).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
await _run(wrapper, _payload(thread_id="thread-nocoerce", run_id="run-1"))
# Turn 2: plain text follow-up with request_info tool call in history
stream2 = await _run(
wrapper,
{
"thread_id": "thread-nocoerce",
"run_id": "run-2",
"messages": [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "flights-choice",
"type": "function",
"function": {"name": "request_info", "arguments": "{}"},
}
],
},
{"role": "user", "content": "I prefer KLM please"},
],
},
)
stream2.assert_has_type("RUN_STARTED")
run_error = stream2.last("RUN_ERROR")
assert run_error.code == "WORKFLOW_RESUME_REQUIRED"
# ──────────────────────────────────────────────────────────────────────
# 17. Workflow factory (thread-scoped workflows)
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_factory_thread_scoping() -> None:
"""workflow_factory creates separate workflow instances per thread_id."""
def make_workflow(thread_id: str):
@executor(id="echo")
async def echo(message: Any, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output(f"Thread: {thread_id}")
return WorkflowBuilder(start_executor=echo).build()
wrapper = AgentFrameworkWorkflow(workflow_factory=make_workflow)
stream_a = await _run(wrapper, _payload(thread_id="thread-a", run_id="run-a"))
stream_b = await _run(wrapper, _payload(thread_id="thread-b", run_id="run-b"))
stream_a.assert_bookends()
stream_b.assert_bookends()
deltas_a = [e.delta for e in stream_a.get("TEXT_MESSAGE_CONTENT")]
deltas_b = [e.delta for e in stream_b.get("TEXT_MESSAGE_CONTENT")]
assert any("thread-a" in d for d in deltas_a)
assert any("thread-b" in d for d in deltas_b)
# ──────────────────────────────────────────────────────────────────────
# 18. Multiple request_info calls in sequence
# ──────────────────────────────────────────────────────────────────────
async def test_workflow_sequential_request_info_interrupts() -> None:
"""Two chained executors each requesting info: first triggers interrupt, resume, then second triggers interrupt.
This mirrors the subgraphs_agent pattern where separate executors handle sequential interactions.
"""
class NameRequester(Executor):
def __init__(self) -> None:
super().__init__(id="name_requester")
@handler
async def start(self, message: Any, ctx: WorkflowContext[str]) -> None:
await ctx.request_info("What's your name?", str, request_id="name-req")
@response_handler
async def handle_name(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(response)
class DestRequester(Executor):
def __init__(self) -> None:
super().__init__(id="dest_requester")
@handler
async def start(self, message: str, ctx: WorkflowContext[str]) -> None:
self._name = message
await ctx.request_info("Where to?", str, request_id="dest-req")
@response_handler
async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
await ctx.yield_output(f"Booking for {self._name} to {response}") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
name_requester = NameRequester()
dest_requester = DestRequester()
workflow = WorkflowBuilder(start_executor=name_requester).add_chain([name_requester, dest_requester]).build()
wrapper = AgentFrameworkWorkflow(workflow=workflow)
# Turn 1
stream1 = await _run(wrapper, _payload(thread_id="thread-seq", run_id="run-1"))
stream1.assert_bookends()
stream1.assert_tool_calls_balanced()
interrupt1 = stream1.run_finished_interrupts()
assert interrupt1[0]["id"] == "name-req"
# Turn 2: answer name → triggers second executor's request_info
stream2 = await _run(
wrapper,
{
"thread_id": "thread-seq",
"run_id": "run-2",
"messages": [],
"resume": {"interrupts": [{"id": "name-req", "value": "Alice"}]},
},
)
stream2.assert_has_run_lifecycle()
stream2.assert_tool_calls_balanced()
interrupt2 = stream2.run_finished_interrupts()
assert interrupt2[0]["id"] == "dest-req"
# Turn 3: answer destination → completion
stream3 = await _run(
wrapper,
{
"thread_id": "thread-seq",
"run_id": "run-3",
"messages": [],
"resume": {"interrupts": [{"id": "dest-req", "value": "Paris"}]},
},
)
stream3.assert_has_run_lifecycle()
stream3.assert_no_run_error()
stream3.assert_text_messages_balanced()
deltas = [e.delta for e in stream3.get("TEXT_MESSAGE_CONTENT")]
assert any("Alice" in d and "Paris" in d for d in deltas)
assert "outcome" not in stream3.last("RUN_FINISHED").model_dump(by_alias=True, exclude_none=True)
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""SSE parsing helpers for AG-UI HTTP round-trip tests."""
from __future__ import annotations
import json
from typing import Any
from event_stream import EventStream # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports]
def parse_sse_response(response_content: bytes) -> list[dict[str, Any]]:
"""Parse raw SSE bytes from TestClient into a list of event dicts.
Each SSE event is a ``data: {...}`` line followed by a blank line.
"""
text = response_content.decode("utf-8")
events: list[dict[str, Any]] = []
decode_errors: list[str] = []
for line in text.splitlines():
if line.startswith("data: "):
payload = line[6:]
try:
events.append(json.loads(payload))
except json.JSONDecodeError as exc:
decode_errors.append(f"payload={payload!r}, error={exc}")
continue
if decode_errors:
joined = "; ".join(decode_errors)
raise AssertionError(f"Failed to decode one or more SSE data lines: {joined}")
return events
def parse_sse_to_event_stream(response_content: bytes) -> EventStream:
"""Parse SSE bytes and wrap in EventStream for structured assertions.
Returns an EventStream over lightweight SimpleNamespace objects that
mirror AG-UI event attributes (type, message_id, tool_call_id, etc.)
so that EventStream assertion methods work.
"""
from types import SimpleNamespace
raw_events = parse_sse_response(response_content)
events: list[Any] = []
for raw in raw_events:
# Normalize camelCase keys to snake_case attributes that EventStream expects
ns = SimpleNamespace()
ns.type = raw.get("type", "")
ns.raw = raw
# Map common camelCase fields
for camel, snake in _FIELD_MAP.items():
if camel in raw:
setattr(ns, snake, raw[camel])
# Also keep camelCase as attributes for direct access
for key, value in raw.items():
if not hasattr(ns, key):
setattr(ns, key, value)
events.append(ns)
return EventStream(events)
_FIELD_MAP: dict[str, str] = {
"messageId": "message_id",
"runId": "run_id",
"threadId": "thread_id",
"toolCallId": "tool_call_id",
"toolCallName": "tool_call_name",
"toolName": "tool_call_name",
"parentMessageId": "parent_message_id",
"stepName": "step_name",
}
@@ -0,0 +1,564 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for AGUIChatClient."""
import json
from collections.abc import AsyncGenerator, Awaitable, MutableSequence
from typing import Any
from ag_ui.core import Interrupt, ResumeEntry
from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
Message,
ResponseStream,
tool,
)
from pytest import MonkeyPatch
from agent_framework_ag_ui._client import AGUIChatClient
from agent_framework_ag_ui._http_service import AGUIHttpService
class StubAGUIChatClient(AGUIChatClient):
"""Testable wrapper exposing protected helpers."""
@property
def http_service(self) -> AGUIHttpService:
"""Expose http service for monkeypatching."""
return self._http_service
def extract_state_from_messages(self, messages: list[Message]) -> tuple[list[Message], dict[str, Any] | None]:
"""Expose state extraction helper."""
return self._extract_state_from_messages(messages)
def convert_messages_to_agui_format(self, messages: list[Message]) -> list[dict[str, Any]]:
"""Expose message conversion helper."""
return self._convert_messages_to_agui_format(messages)
def get_thread_id(self, options: ChatOptions[Any] | dict[str, Any] | None) -> str:
"""Expose thread id helper."""
return self._get_thread_id(options) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
def inner_get_response(
self,
*,
messages: MutableSequence[Message],
options: ChatOptions[Any] | dict[str, Any] | None,
stream: bool = False,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
"""Proxy to protected response call."""
return self._inner_get_response(messages=messages, options=options, stream=stream) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
class TestAGUIChatClient:
"""Test suite for AGUIChatClient."""
async def test_client_initialization(self) -> None:
"""Test client initialization."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
assert client.http_service is not None
assert client.http_service.endpoint.startswith("http://localhost:8888")
async def test_client_context_manager(self) -> None:
"""Test client as async context manager."""
async with StubAGUIChatClient(endpoint="http://localhost:8888/") as client:
assert client is not None
async def test_extract_state_from_messages_no_state(self) -> None:
"""Test state extraction when no state is present."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
messages = [
Message(role="user", contents=["Hello"]),
Message(role="assistant", contents=["Hi there"]),
]
result_messages, state = client.extract_state_from_messages(messages)
assert result_messages == messages
assert state is None
async def test_extract_state_from_messages_with_state(self) -> None:
"""Test state extraction from last message."""
import base64
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
state_data = {"key": "value", "count": 42}
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
messages = [
Message(role="user", contents=["Hello"]),
Message(
role="user",
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
result_messages, state = client.extract_state_from_messages(messages)
assert len(result_messages) == 1
assert result_messages[0].text == "Hello"
assert state == state_data
async def test_extract_state_invalid_json(self) -> None:
"""Test state extraction with invalid JSON."""
import base64
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
invalid_json = "not valid json"
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
messages = [
Message(
role="user",
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
result_messages, state = client.extract_state_from_messages(messages)
assert result_messages == messages
assert state is None
async def test_convert_messages_to_agui_format(self) -> None:
"""Test message conversion to AG-UI format."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
messages = [
Message(role="user", contents=["What is the weather?"]),
Message(role="assistant", contents=["Let me check."], message_id="msg_123"),
]
agui_messages = client.convert_messages_to_agui_format(messages)
assert len(agui_messages) == 2
assert agui_messages[0]["role"] == "user"
assert agui_messages[0]["content"] == "What is the weather?"
assert agui_messages[1]["role"] == "assistant"
assert agui_messages[1]["content"] == "Let me check."
assert agui_messages[1]["id"] == "msg_123"
async def test_get_thread_id_from_metadata(self) -> None:
"""Test thread ID extraction from metadata."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"})
thread_id = client.get_thread_id(chat_options)
assert thread_id == "existing_thread_123"
async def test_get_thread_id_generation(self) -> None:
"""Test automatic thread ID generation."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
chat_options = ChatOptions()
thread_id = client.get_thread_id(chat_options)
assert thread_id.startswith("thread_")
assert len(thread_id) > 7
async def test_get_response_streaming(self, monkeypatch: MonkeyPatch) -> None:
"""Test streaming response method."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": " world"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test message"])]
chat_options = ChatOptions()
updates: list[ChatResponseUpdate] = []
stream = client.inner_get_response(messages=messages, stream=True, options=chat_options)
assert isinstance(stream, ResponseStream)
async for update in stream:
updates.append(update)
assert len(updates) == 4
assert updates[0].additional_properties is not None
assert updates[0].additional_properties["thread_id"] == "thread_1"
first_content = updates[1].contents[0]
second_content = updates[2].contents[0]
assert first_content.type == "text"
assert second_content.type == "text"
assert first_content.text == "Hello"
assert second_content.text == " world"
async def test_get_response_non_streaming(self, monkeypatch: MonkeyPatch) -> None:
"""Test non-streaming response method."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Complete response"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test message"])]
chat_options: dict[str, Any] = {}
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
assert len(response.messages) > 0
assert "Complete response" in response.text
async def test_tool_handling(self, monkeypatch: MonkeyPatch) -> None:
"""Test that client tool metadata is sent to server.
Client tool metadata (name, description, schema) is sent to server for planning.
When server requests a client function, function invocation mixin
intercepts and executes it locally. This matches .NET AG-UI implementation.
"""
from agent_framework import tool
@tool
def test_tool(param: str) -> str:
"""Test tool."""
return "result"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
# Client tool metadata should be sent to server
tools: list[dict[str, Any]] | None = kwargs.get("tools")
assert tools is not None
assert len(tools) == 1
tool_entry = tools[0]
assert tool_entry["name"] == "test_tool"
assert tool_entry["description"] == "Test tool."
assert "parameters" in tool_entry
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test with tools"])]
chat_options = ChatOptions(tools=[test_tool])
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
async def test_server_tool_calls_unwrapped_after_invocation(self, monkeypatch: MonkeyPatch) -> None:
"""Ensure server-side tool calls are exposed as FunctionCallContent after processing."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test server tool execution"])]
updates: list[ChatResponseUpdate] = []
async for update in client.get_response(messages, stream=True):
updates.append(update)
function_calls = [
content for update in updates for content in update.contents if content.type == "function_call"
]
assert function_calls
assert function_calls[0].name == "get_time_zone"
assert not any(content.type == "server_function_call" for update in updates for content in update.contents)
async def test_server_tool_calls_not_executed_locally(self, monkeypatch: MonkeyPatch) -> None:
"""Server tools should not trigger local function invocation even when client tools exist."""
@tool
def client_tool() -> str:
"""Client tool stub."""
return "client"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "get_time_zone"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"location": "Seattle"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
async def fake_auto_invoke(*args: object, **kwargs: Any) -> None:
function_call = kwargs.get("function_call_content") or args[0]
raise AssertionError(f"Unexpected local execution of server tool: {getattr(function_call, 'name', '?')}")
monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke)
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test server tool execution"])]
async for _ in client.get_response(
messages, stream=True, options={"tool_choice": "auto", "tools": [client_tool]}
):
pass
async def test_state_transmission(self, monkeypatch: MonkeyPatch) -> None:
"""Test state is properly transmitted to server."""
import base64
state_data = {"user_id": "123", "session": "abc"}
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
messages = [
Message(role="user", contents=["Hello"]),
Message(
role="user",
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
assert kwargs.get("state") == state_data
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
chat_options = ChatOptions()
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
async def test_extract_state_from_empty_messages(self) -> None:
"""Empty messages list returns empty list and None state."""
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
result_messages, state = client.extract_state_from_messages([])
assert result_messages == []
assert state is None
async def test_register_server_tool_non_dict_config(self) -> None:
"""Non-dict function_invocation_configuration is a no-op."""
client = StubAGUIChatClient(
endpoint="http://localhost:8888/",
function_invocation_configuration=None, # type: ignore[arg-type]
)
# Should not raise
client._register_server_tool_placeholder("some_tool")
async def test_non_streaming_response(self, monkeypatch: MonkeyPatch) -> None:
"""Non-streaming path collects updates into ChatResponse."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test"])]
response = await client.inner_get_response(messages=messages, options={}, stream=False)
assert response is not None
assert len(response.messages) > 0
async def test_client_tool_sets_additional_properties(self, monkeypatch: MonkeyPatch) -> None:
"""Client tool content gets agui_thread_id additional property."""
@tool
def my_tool(param: str) -> str:
"""My tool."""
return "result"
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "my_tool"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"param": "test"}'},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["Test"])]
updates: list[ChatResponseUpdate] = []
stream = client.inner_get_response(messages=messages, stream=True, options={"tools": [my_tool]})
assert isinstance(stream, ResponseStream)
async for update in stream:
updates.append(update)
# Find the function_call content - it should have agui_thread_id
found = False
for update in updates:
for content in update.contents:
if content.type == "function_call" and content.name == "my_tool":
assert content.additional_properties is not None
assert "agui_thread_id" in content.additional_properties
found = True
break
assert found, "Expected to find function_call content for my_tool"
async def test_tool_call_args_id_mismatch_does_not_execute_current_client_tool(
self, monkeypatch: MonkeyPatch
) -> None:
"""Mismatched TOOL_CALL_ARGS must not be rebound to the latest client tool."""
executed: list[int] = []
@tool
def danger_tool(amount: int) -> str:
"""Record an invocation for the regression assertion."""
executed.append(amount)
return f"danger={amount}"
call_count = 0
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
nonlocal call_count
call_count += 1
if call_count == 1:
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "TOOL_CALL_START", "toolCallId": "safe", "toolName": "safe_tool"},
{"type": "TOOL_CALL_START", "toolCallId": "danger", "toolName": "danger_tool"},
{"type": "TOOL_CALL_ARGS", "toolCallId": "safe", "delta": '{"amount": 100}'},
{"type": "TOOL_CALL_END", "toolCallId": "safe"},
{"type": "TOOL_CALL_END", "toolCallId": "danger"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
else:
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_2"},
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "done"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_2"},
]
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
response = await client.get_response(
[Message(role="user", contents=["Test"])],
options={"tools": [danger_tool]},
)
assert response.text == "done"
assert executed == []
async def test_interrupt_options_transmission(self, monkeypatch: MonkeyPatch) -> None:
"""Interrupt option fields are forwarded to the HTTP service."""
available_interrupts = [{"id": "req_1", "type": "request_info"}]
expected_available_interrupts = [{"id": "req_1", "reason": "input_required"}]
resume_payload = {"interrupts": [{"id": "req_1", "value": "approved"}]}
expected_resume_payload = [{"interruptId": "req_1", "status": "resolved", "payload": "approved"}]
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
assert kwargs.get("available_interrupts") == expected_available_interrupts
assert kwargs.get("resume") == expected_resume_payload
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [Message(role="user", contents=["continue"])]
options = {
"available_interrupts": available_interrupts,
"resume": resume_payload,
}
response = await client.inner_get_response(messages=messages, options=options)
assert response is not None
async def test_typed_interrupt_options_forward_canonical_protocol_shape(self, monkeypatch: MonkeyPatch) -> None:
"""Typed interrupt options are forwarded as canonical protocol JSON."""
mock_events = [
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
]
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
assert kwargs.get("available_interrupts") == [
{
"id": "approval_1",
"reason": "tool_call",
"toolCallId": "call_1",
"responseSchema": {"type": "object"},
}
]
assert kwargs.get("resume") == [
{"interruptId": "approval_1", "status": "resolved", "payload": {"approved": True}}
]
for event in mock_events:
yield event
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
options: dict[str, Any] = {
"available_interrupts": [
Interrupt(
id="approval_1",
reason="tool_call",
tool_call_id="call_1",
response_schema={"type": "object"},
)
],
"resume": [ResumeEntry(interrupt_id="approval_1", status="resolved", payload={"approved": True})],
}
response = await client.inner_get_response(
messages=[Message(role="user", contents=["continue"])],
options=options,
)
assert response is not None

Some files were not shown because too many files have changed in this diff Show More