From 8ad66637d8072c36634b464aac31cd0347888bc7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 10 Feb 2026 13:13:38 +0100 Subject: [PATCH 01/10] Python: Fix prek runner duplication and add skills (#3791) * Python: fix prek runner running fmt/lint in all packages on core change When a core package file changed, run_tasks_in_changed_packages.py ran fmt, lint, and pyright in ALL 22 packages (66 tasks). Only type-checking tasks (pyright, mypy) need to propagate to all packages since type changes in core affect downstream packages. File-local tasks (fmt, lint) only need to run in packages with actual file changes. This reduces a core-only change from 66 tasks to 24 tasks (2 local + 22 pyright). Also adds no-commit-to-branch builtin hook to protect the main branch from direct commits. * Python: add agent skills extracted from AGENTS.md and coding standards Add 5 skills to python/.github/skills/ following the Agent Skills format: - python-development: coding standards, type annotations, docstrings, logging - python-testing: test structure, fixtures, running tests, async mode - python-code-quality: linting, formatting, type checking, prek hooks, CI - python-package-management: monorepo structure, lazy loading, versioning - python-samples: sample structure, PEP 723, documentation guidelines * Python: deduplicate AGENTS.md and instructions with agent skills * updated skills * fixes from review * Python: increase timeout for web search integration test --- .../instructions/python.instructions.md | 11 +- .../skills/python-code-quality/SKILL.md | 85 ++++++++++++++ .../skills/python-development/SKILL.md | 109 ++++++++++++++++++ .../skills/python-package-management/SKILL.md | 103 +++++++++++++++++ python/.github/skills/python-samples/SKILL.md | 77 +++++++++++++ python/.github/skills/python-testing/SKILL.md | 84 ++++++++++++++ python/.pre-commit-config.yaml | 3 + python/AGENTS.md | 42 ++----- .../openai/test_openai_responses_client.py | 1 + .../scripts/run_tasks_in_changed_packages.py | 54 ++++++--- 10 files changed, 513 insertions(+), 56 deletions(-) create mode 100644 python/.github/skills/python-code-quality/SKILL.md create mode 100644 python/.github/skills/python-development/SKILL.md create mode 100644 python/.github/skills/python-package-management/SKILL.md create mode 100644 python/.github/skills/python-samples/SKILL.md create mode 100644 python/.github/skills/python-testing/SKILL.md diff --git a/python/.github/instructions/python.instructions.md b/python/.github/instructions/python.instructions.md index 6c478a0395..ccd77f3045 100644 --- a/python/.github/instructions/python.instructions.md +++ b/python/.github/instructions/python.instructions.md @@ -1,11 +1,6 @@ --- -applyTo: '**/agent-framework/python/**' +applyTo: 'python/**' --- -See [AGENTS.md](../../AGENTS.md) for project structure, commands, and conventions. - -Additional guidance: -- Review existing tests and samples to understand coding style before creating new ones -- When verifying logic, run only related tests, not the entire suite -- Resolve all errors and warnings before running code -- Use print statements for debugging, then remove them when done +See [AGENTS.md](../../AGENTS.md) for project structure and package documentation. +Detailed conventions are in the agent skills under `.github/skills/`. diff --git a/python/.github/skills/python-code-quality/SKILL.md b/python/.github/skills/python-code-quality/SKILL.md new file mode 100644 index 0000000000..9a1ba521b3 --- /dev/null +++ b/python/.github/skills/python-code-quality/SKILL.md @@ -0,0 +1,85 @@ +--- +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 +# Format code (ruff format, parallel across packages) +uv run poe fmt + +# Lint and auto-fix (ruff check, parallel across packages) +uv run poe lint + +# Type checking +uv run poe pyright # Pyright (parallel across packages) +uv run poe mypy # MyPy (parallel across packages) +uv run poe typing # Both pyright and mypy + +# All package-level checks in parallel (fmt + lint + pyright + mypy) +uv run poe check-packages + +# Full check (packages + samples + tests + markdown) +uv run poe check + +# Samples only +uv run poe samples-lint # Ruff lint on samples/ +uv run poe samples-syntax # Pyright syntax check on samples/ + +# Markdown code blocks +uv run poe markdown-code-lint +``` + +## Pre-commit Hooks (prek) + +Prek hooks run automatically on commit. They check only changed files and run +package-level checks in parallel for affected packages only. + +```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 +``` + +When core package changes, type-checking (mypy, pyright) runs across all packages +since type changes propagate. Format and lint only run in changed packages. + +## 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 + +- Strict mode enabled +- Excludes: tests, .venv, packages/devui/frontend + +## 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** — fmt/lint/pyright via check-packages +3. **Samples & markdown** — samples-lint, samples-syntax, markdown-code-lint +4. **Mypy** — change-detected mypy checks diff --git a/python/.github/skills/python-development/SKILL.md b/python/.github/skills/python-development/SKILL.md new file mode 100644 index 0000000000..7b119d13d8 --- /dev/null +++ b/python/.github/skills/python-development/SKILL.md @@ -0,0 +1,109 @@ +--- +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 + +## 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 ChatAgent, ChatMessage, tool + +# Components +from agent_framework.observability import enable_instrumentation + +# Connectors (lazy-loaded) +from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient +``` + +## Public API and Exports + +Define `__all__` in each module. Avoid `from module import *` in `__init__.py` files: + +```python +__all__ = ["ChatAgent", "ChatMessage", "ChatResponse"] + +from ._agents import ChatAgent +from ._types import ChatMessage, ChatResponse +``` + +## 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__for_` for methods that prepare data for external services +- `_parse__from_` for methods that process data from external services diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md new file mode 100644 index 0000000000..8784aed453 --- /dev/null +++ b/python/.github/skills/python-package-management/SKILL.md @@ -0,0 +1,103 @@ +--- +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) +│ ├── azure-ai/ # agent-framework-azure-ai +│ ├── 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/update all dependencies +uv run poe install + +# Create venv with specific Python version +uv run poe venv --python 3.12 +``` + +## Lazy Loading Pattern + +Provider folders in core use `__getattr__` to lazy load from connector packages: + +```python +# In agent_framework/azure/__init__.py +_IMPORTS: dict[str, tuple[str, str]] = { + "AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), +} + +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. + +### Initial Release (Preview) + +1. Create directory under `packages/` (e.g., `packages/my-connector/`) +2. Add the package to `tool.uv.sources` in root `pyproject.toml` +3. Include samples inside the package (e.g., `packages/my-connector/samples/`) +4. Do **NOT** add to `[all]` extra in `packages/core/pyproject.toml` +5. Do **NOT** create lazy loading in core yet + +### Promotion to Stable + +1. Move samples to root `samples/` folder +2. Add to `[all]` extra in `packages/core/pyproject.toml` +3. Create provider folder in `agent_framework/` with lazy loading `__init__.py` + +## Versioning + +- 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 + +## 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-azure-ai # 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 diff --git a/python/.github/skills/python-samples/SKILL.md b/python/.github/skills/python-samples/SKILL.md new file mode 100644 index 0000000000..b70862eb8a --- /dev/null +++ b/python/.github/skills/python-samples/SKILL.md @@ -0,0 +1,77 @@ +--- +name: python-samples +description: > + Guidelines for creating and modifying sample code in the Agent Framework + Python codebase. Use this when writing new samples or updating existing ones. +--- + +# Python Samples + +## File Structure + +Every sample file follows this order: + +1. PEP 723 inline script metadata (if external dependencies needed) +2. Copyright header: `# Copyright (c) Microsoft. All rights reserved.` +3. Required imports +4. Module docstring: `"""This sample demonstrates..."""` +5. Helper functions +6. Main function(s) demonstrating functionality +7. Entry point: `if __name__ == "__main__": asyncio.run(main())` + +## External Dependencies + +Use [PEP 723](https://peps.python.org/pep-0723/) inline script metadata for +external packages not in the dev environment: + +```python +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "some-external-package", +# ] +# /// +# Run with: uv run samples/path/to/script.py + +# Copyright (c) Microsoft. All rights reserved. +``` + +Do **not** add sample-only dependencies to the root `pyproject.toml` dev group. + +## Syntax Checking + +```bash +# Check samples for syntax errors and missing imports +uv run poe samples-syntax + +# Lint samples +uv run poe samples-lint +``` + +## Documentation + +Samples should be over-documented: + +1. Include a README.md in each set of samples +2. Add a summary docstring under imports explaining the purpose and key components +3. Mark code sections with numbered comments: + ```python + # 1. Create the client instance. + ... + # 2. Create the agent with the client. + ... + ``` +4. Include expected output at the end of the file: + ```python + """ + Sample output: + User:> Why is the sky blue? + Assistant:> The sky is blue due to Rayleigh scattering... + """ + ``` + +## Guidelines + +- **Incremental complexity** — start simple, build up (step1, step2, ...) +- **Getting started naming**: `step_.py` +- When modifying samples, update associated README files diff --git a/python/.github/skills/python-testing/SKILL.md b/python/.github/skills/python-testing/SKILL.md new file mode 100644 index 0000000000..d38423b72d --- /dev/null +++ b/python/.github/skills/python-testing/SKILL.md @@ -0,0 +1,84 @@ +--- +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 `RUN_INTEGRATION_TESTS=false` (unit tests only), and the full suite with `RUN_INTEGRATION_TESTS=true` is run when merging. + +## Running Tests + +```bash +# Run tests for all packages in parallel +uv run poe test + +# Run tests for a specific package +uv run --directory packages/core poe test + +# Run all tests in a single pytest invocation (faster, uses pytest-xdist) +uv run poe all-tests + +# With coverage +uv run poe all-tests-cov +``` + +## 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 + +## 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 + +Tests marked with `@skip_if_..._integration_tests_disabled` require: +- `RUN_INTEGRATION_TESTS=true` environment variable +- Appropriate API keys in environment or `.env` file + +## 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 diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index 24de9fc7a0..dfdf60a61b 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -31,6 +31,9 @@ repos: 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] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/python/AGENTS.md b/python/AGENTS.md index 62d52608a3..1a7e430195 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -5,13 +5,20 @@ 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-package-management` — monorepo structure, lazy loading, versioning, new packages +- `python-samples` — sample file structure, PEP 723, documentation guidelines ## Maintaining Documentation -When making changes to a package, check if the package's `AGENTS.md` file needs updates. This includes: -- Adding/removing/renaming public classes or functions -- Changing the package's purpose or architecture -- Modifying import paths or usage patterns +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 ## Quick Reference @@ -30,6 +37,7 @@ python/ │ ├── ollama/ # agent-framework-ollama │ └── ... # Other provider packages ├── samples/ # Sample code and examples +├── .github/skills/ # Agent skills for Copilot └── tests/ # Integration tests ``` @@ -39,32 +47,6 @@ python/ - Provider packages (`azure-ai`, `anthropic`, etc.) extend core with specific integrations - Core uses lazy loading via `__getattr__` in provider folders (e.g., `agent_framework/azure/`) -### Import Patterns - -```python -# Core imports -from agent_framework import ChatAgent, ChatMessage, tool - -# Provider imports (lazy-loaded) -from agent_framework.openai import OpenAIChatClient -from agent_framework.azure import AzureOpenAIChatClient, AzureAIAgentClient -``` - -## Key Conventions - -- **Copyright**: `# Copyright (c) Microsoft. All rights reserved.` at top of all `.py` files -- **Types**: Always specify return types and parameter types; use `Type | None` not `Optional` -- **Logging**: `from agent_framework import get_logger` (never `import logging`) -- **Docstrings**: Google-style for public APIs -- **Tests**: Do not use `@pytest.mark.asyncio` (auto mode enabled); run only related tests, not the entire suite -- **Line length**: 120 characters -- **Comments**: Avoid excessive comments; prefer clear code -- **Formatting**: Format only files you changed, not the entire codebase - -## Samples - -See [samples/SAMPLE_GUIDELINES.md](samples/SAMPLE_GUIDELINES.md) for sample structure, external dependency handling (PEP 723), and syntax checking instructions. - ## Package Documentation ### Core diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index dac6bf23e8..88a20285d2 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -2259,6 +2259,7 @@ async def test_integration_options( assert "seattle" in response_value["location"].lower() +@pytest.mark.timeout(300) @pytest.mark.flaky @skip_if_openai_integration_tests_disabled async def test_integration_web_search() -> None: diff --git a/python/scripts/run_tasks_in_changed_packages.py b/python/scripts/run_tasks_in_changed_packages.py index 9773e278d4..0c33cb7f83 100644 --- a/python/scripts/run_tasks_in_changed_packages.py +++ b/python/scripts/run_tasks_in_changed_packages.py @@ -8,9 +8,18 @@ from pathlib import Path from rich import print from task_runner import build_work_items, discover_projects, run_tasks +# Tasks that need to run in all packages when core changes (type info propagates) +TYPE_CHECK_TASKS = {"pyright", "mypy"} -def get_changed_packages(projects: list[Path], changed_files: list[str], workspace_root: Path) -> set[Path]: - """Determine which packages have changed files.""" + +def get_changed_packages( + projects: list[Path], changed_files: list[str], workspace_root: Path +) -> tuple[set[Path], bool]: + """Determine which packages have changed files. + + Returns: + A tuple of (changed_packages, core_package_changed). + """ changed_packages: set[Path] = set() core_package_changed = False @@ -32,20 +41,13 @@ def get_changed_packages(projects: list[Path], changed_files: list[str], workspa # Check if the file is within this project directory abs_path.relative_to(project_abs) changed_packages.add(project) - # Check if the core package was changed if project == Path("packages/core"): core_package_changed = True break except ValueError: - # File is not in this project continue - # If core package changed, check all packages - if core_package_changed: - print("[yellow]Core package changed - checking all packages[/yellow]") - return set(projects) - - return changed_packages + return changed_packages, core_package_changed def main() -> None: @@ -63,17 +65,33 @@ def main() -> None: if not args.files or args.files == ["."]: task_list = ", ".join(args.tasks) print(f"[yellow]No specific files provided, running {task_list} in all packages[/yellow]") - target_packages = sorted(set(projects)) + work_items = build_work_items(sorted(set(projects)), args.tasks) else: - changed_packages = get_changed_packages(projects, args.files, workspace_root) - if changed_packages: - print(f"[cyan]Detected changes in packages: {', '.join(str(p) for p in sorted(changed_packages))}[/cyan]") - else: - print(f"[yellow]No changes detected in any package, skipping[/yellow]") + changed_packages, core_changed = get_changed_packages(projects, args.files, workspace_root) + if not changed_packages: + print("[yellow]No changes detected in any package, skipping[/yellow]") return - target_packages = sorted(changed_packages) - work_items = build_work_items(target_packages, args.tasks) + print(f"[cyan]Detected changes in packages: {', '.join(str(p) for p in sorted(changed_packages))}[/cyan]") + + # File-local tasks (fmt, lint) only run in packages with actual changes. + # Type-checking tasks (pyright, mypy) run in all packages when core changes, + # because type changes in core propagate to downstream packages. + local_tasks = [t for t in args.tasks if t not in TYPE_CHECK_TASKS] + type_tasks = [t for t in args.tasks if t in TYPE_CHECK_TASKS] + + work_items = build_work_items(sorted(changed_packages), local_tasks) + if type_tasks: + if core_changed: + print("[yellow]Core package changed - type-checking all packages[/yellow]") + work_items += build_work_items(sorted(set(projects)), type_tasks) + else: + work_items += build_work_items(sorted(changed_packages), type_tasks) + + if not work_items: + print("[yellow]No matching tasks found in any package[/yellow]") + return + run_tasks(work_items, workspace_root, sequential=args.seq) From 6c37ce845060551479c84d5a048d0152fbf656ca Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:50:41 -0500 Subject: [PATCH 02/10] Fix streaming path to include chat history in messages sent to chat client (#3798) RunCoreStreamingAsync was passing inputMessagesForProviders (which lacks chat history) to GetStreamingResponseAsync instead of inputMessagesForChatClient (which includes chat history). This caused streaming runs to lose conversation context on subsequent calls. The non-streaming path (RunCoreAsync) already correctly used inputMessagesForChatClient. This aligns the streaming path to match. Also adds a unit test that validates chat history is included in messages sent to the chat client during streaming on subsequent calls. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../ChatClient/ChatClientAgent.cs | 2 +- .../ChatClient/ChatClientAgentTests.cs | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 5878d877b2..37e673f710 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -226,7 +226,7 @@ public sealed partial class ChatClientAgent : AIAgent try { // Using the enumerator to ensure we consider the case where no updates are returned for notification. - responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForProviders, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); + responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForChatClient, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 41fb29bfed..c23e8cffaf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1320,6 +1320,45 @@ public partial class ChatClientAgentTests mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); } + /// + /// Verify that RunStreamingAsync includes chat history in messages sent to the chat client on subsequent calls. + /// + [Fact] + public async Task RunStreamingAsyncIncludesChatHistoryInMessagesToChatClientAsync() + { + // Arrange + List> capturedMessages = []; + Mock mockService = new(); + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"), + ]; + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(returnUpdates)) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages.Add(msgs.ToList())); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + }); + + // Act + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunStreamingAsync([new(ChatRole.User, "first")], session).ToListAsync(); + await agent.RunStreamingAsync([new(ChatRole.User, "second")], session).ToListAsync(); + + // Assert - the second call should include chat history (first user message + first response) plus the new message + Assert.Equal(2, capturedMessages.Count); + var secondCallMessages = capturedMessages[1].ToList(); + Assert.Equal(3, secondCallMessages.Count); + Assert.Equal("first", secondCallMessages[0].Text); + Assert.Equal("response", secondCallMessages[1].Text); + Assert.Equal("second", secondCallMessages[2].Text); + } + /// /// Verify that RunStreamingAsync throws when a factory is provided and the chat client returns a conversation id. /// From e489ac0fa39ea0ea28430e0b643ca417ca52cf47 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 10 Feb 2026 10:50:57 -0500 Subject: [PATCH 03/10] .NET: Fix: Checkpoint Deserialization breaks when JSON metadata properties are out of order (#3442) * Fix checkpoint JSON deserialization with out-of-order metadata properties (#2962) * Simplify: propagate AllowOutOfOrderMetadataProperties from incoming JsonSerializerOptions --- .../Checkpointing/JsonMarshaller.cs | 8 +- .../JsonSerializationTests.cs | 100 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs index a6a69f258f..1a6a55dd3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs @@ -13,7 +13,13 @@ internal sealed class JsonMarshaller : IWireMarshaller public JsonMarshaller(JsonSerializerOptions? serializerOptions = null) { - this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions); + this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions) + { + // Propagate from the user-provided options if set; enables support for databases + // like PostgreSQL jsonb that do not preserve property order. + AllowOutOfOrderMetadataProperties = serializerOptions?.AllowOutOfOrderMetadataProperties is true, + }; + this._internalOptions.Converters.Add(new PortableValueConverter(this)); this._internalOptions.Converters.Add(new ExecutorIdentityConverter()); this._internalOptions.Converters.Add(new ScopeKeyConverter()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 686cdea308..c2a538b302 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -672,4 +672,104 @@ public class JsonSerializationTests ValidateCheckpoint(retrievedCheckpoint, prototype); } + + /// + /// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails + /// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue. + /// See: https://github.com/microsoft/agent-framework/issues/2962 + /// + [Fact] + public void Test_OutOfOrderMetadataProperties_WithoutOption_Fails() + { + // Arrange + JsonMarshaller marshaller = new(); + EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition; + + // Serialize to JSON + JsonElement serialized = marshaller.Marshal(edgeInfo); + string json = serialized.GetRawText(); + + // Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first + string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json); + + // Act & Assert - Without the option, deserialization should fail + JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement; + Action act = () => marshaller.Marshal(reorderedElement); + + act.Should().Throw(); + } + + /// + /// Simulates PostgreSQL jsonb behavior where property order is not preserved, + /// causing $type metadata to not be the first property. + /// This test verifies that deserialization works when AllowOutOfOrderMetadataProperties is enabled. + /// See: https://github.com/microsoft/agent-framework/issues/2962 + /// + [Fact] + public void Test_OutOfOrderMetadataProperties_WithOptionEnabled_Succeeds() + { + // Arrange + EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition; + + // Serialize to JSON using standard marshaller + JsonMarshaller marshaller = new(); + JsonElement serialized = marshaller.Marshal(edgeInfo); + string json = serialized.GetRawText(); + + // Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first + string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json); + JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement; + + // Act - Deserialize with AllowOutOfOrderMetadataProperties enabled via JsonSerializerOptions + JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true }; + JsonMarshaller marshallerWithOption = new(options); + EdgeInfo deserialized = marshallerWithOption.Marshal(reorderedElement); + + // Assert + deserialized.Should().Match(edgeInfo.CreatePolyValidator()); + } + + private static string ReorderJsonPropertiesToMoveTypeDiscriminatorLast(string json) + { + // Parse JSON, extract $type, rebuild with $type at end + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + Dictionary properties = []; + JsonElement? typeValue = null; + + foreach (JsonProperty prop in root.EnumerateObject()) + { + if (prop.Name == "$type") + { + typeValue = prop.Value.Clone(); + } + else + { + properties[prop.Name] = prop.Value.Clone(); + } + } + + // Rebuild JSON with $type last + using System.IO.MemoryStream ms = new(); + using (Utf8JsonWriter writer = new(ms)) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in properties) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + + if (typeValue.HasValue) + { + writer.WritePropertyName("$type"); + typeValue.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } } From aa44e63074c8fe97bf0dbdd59432e5cb3f983b68 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:50:55 +0000 Subject: [PATCH 04/10] .NET: Add Foundry Agents Tool Sample - Local MCP (#3703) * .NET: Add Local MCP sample #3674 * Apply format fixes * .NET: Use local MCP client instead of hosted MCP in Step27 sample * Address PR review feedback: cleanup, try/finally, update parent README --- .../FoundryAgents_Step27_LocalMCP.csproj | 22 +++++ .../FoundryAgents_Step27_LocalMCP/Program.cs | 83 +++++++++++++++++++ .../FoundryAgents_Step27_LocalMCP/README.md | 48 +++++++++++ .../GettingStarted/FoundryAgents/README.md | 1 + 4 files changed, 154 insertions(+) create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj new file mode 100644 index 0000000000..1e3e6f57e3 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/FoundryAgents_Step27_LocalMCP.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs new file mode 100644 index 0000000000..6b1d22c14b --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/Program.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. +// The MCP tools are resolved locally by connecting directly to the MCP server via HTTP, +// and then passed to the Foundry agent as client-side tools. +// This sample uses the Microsoft Learn MCP endpoint to search documentation. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +const string AgentInstructions = "You are a helpful assistant that can help with Microsoft documentation questions. Use the Microsoft Learn MCP tool to search for documentation."; +const string AgentName = "DocsAgent"; + +// Connect to the MCP server locally via HTTP (Streamable HTTP transport). +// The MCP server is hosted at Microsoft Learn and provides documentation search capabilities. +Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); + +await using McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); + +// Retrieve the list of tools available on the MCP server (resolved locally). +IList mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +// Wrap each MCP tool with a DelegatingAIFunction to log local invocations. +List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); + +// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); + +// Create the agent with the locally-resolved MCP tools. +AIAgent agent = await aiProjectClient.CreateAIAgentAsync( + model: deploymentName, + name: AgentName, + instructions: AgentInstructions, + tools: wrappedTools); + +Console.WriteLine($"Agent '{agent.Name}' created successfully."); + +try +{ + // First query + const string Prompt1 = "How does one create an Azure storage account using az cli?"; + Console.WriteLine($"\nUser: {Prompt1}\n"); + AgentResponse response1 = await agent.RunAsync(Prompt1); + Console.WriteLine($"Agent: {response1}"); + + Console.WriteLine("\n=======================================\n"); + + // Second query + const string Prompt2 = "What is Microsoft Agent Framework?"; + Console.WriteLine($"User: {Prompt2}\n"); + AgentResponse response2 = await agent.RunAsync(Prompt2); + Console.WriteLine($"Agent: {response2}"); +} +finally +{ + // Cleanup by removing the agent when done + await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); + Console.WriteLine($"\nAgent '{agent.Name}' deleted."); +} + +/// +/// Wraps an MCP tool to log when it is invoked locally, +/// confirming that the MCP call is happening client-side. +/// +internal sealed class LoggingMcpTool(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) +{ + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + Console.WriteLine($" >> [LOCAL MCP] Invoking tool '{this.Name}' locally..."); + return base.InvokeCoreAsync(arguments, cancellationToken); + } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md new file mode 100644 index 0000000000..e8883b7e95 --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step27_LocalMCP/README.md @@ -0,0 +1,48 @@ +# Using Local MCP Client with Azure Foundry Agents + +This sample demonstrates how to use a local MCP (Model Context Protocol) client with Azure Foundry Agents. Unlike the hosted MCP approach where Azure Foundry invokes the MCP server on the service side, this sample connects to the MCP server directly from the client via HTTP (Streamable HTTP transport) and passes the resolved tools to the agent. + +## What this sample demonstrates + +- Connecting to an MCP server locally using `HttpClientTransport` +- Discovering available tools from the MCP server client-side +- Passing locally-resolved MCP tools to a Foundry agent +- Using the Microsoft Learn MCP endpoint for documentation search +- Managing agent lifecycle (creation and deletion) + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Azure Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +Set the following environment variables: + +```powershell +$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint +$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step27_LocalMCP +``` + +## Expected behavior + +The sample will: + +1. Connect to the Microsoft Learn MCP server via HTTP and list available tools +2. Create an agent with the locally-resolved MCP tools +3. Ask two questions about Microsoft documentation +4. The agent will use the MCP tools (invoked locally) to search Microsoft Learn documentation +5. Display the agent's responses with information from the documentation +6. Clean up resources by deleting the agent diff --git a/dotnet/samples/GettingStarted/FoundryAgents/README.md b/dotnet/samples/GettingStarted/FoundryAgents/README.md index ba5af8de5a..d7bfe4d035 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/README.md @@ -58,6 +58,7 @@ Before you begin, ensure you have the following prerequisites: |[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent| |[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent| |[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent| +|[Local MCP](./FoundryAgents_Step27_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent| ## Running the samples from the console From f106a1a2b1903abd6b401e29c9839e31eca05ba4 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:24:27 +0900 Subject: [PATCH 05/10] Python: Include sub-workflow structure in graph signature for checkpoint validation (#3783) * Include sub-workflow structure in graph signature for checkpoint validation * Remove redundant computation --- .../agent_framework/_workflows/_workflow.py | 17 +++- .../workflow/test_checkpoint_validation.py | 85 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 5f93644035..08e7512234 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -744,10 +744,19 @@ class Workflow(DictConvertible): ignoring data/state changes. Used to verify that a workflow's structure hasn't changed when resuming from checkpoints. """ - executors_signature = { - executor_id: f"{executor.__class__.__module__}.{executor.__class__.__name__}" - for executor_id, executor in self.executors.items() - } + from ._workflow_executor import WorkflowExecutor + + executors_signature = {} + for executor_id, executor in self.executors.items(): + executor_sig: Any = f"{executor.__class__.__module__}.{executor.__class__.__name__}" + + if isinstance(executor, WorkflowExecutor): + executor_sig = { + "type": executor_sig, + "sub_workflow": executor.workflow._graph_signature, + } + + executors_signature[executor_id] = executor_sig edge_groups_signature: list[dict[str, Any]] = [] for group in self.edge_groups: diff --git a/python/packages/core/tests/workflow/test_checkpoint_validation.py b/python/packages/core/tests/workflow/test_checkpoint_validation.py index c028a94b40..17175451ce 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_validation.py +++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py @@ -7,6 +7,7 @@ from agent_framework import ( WorkflowBuilder, WorkflowCheckpointException, WorkflowContext, + WorkflowExecutor, WorkflowRunState, handler, ) @@ -81,3 +82,87 @@ async def test_resume_succeeds_when_graph_matches() -> None: ] assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events) + + +# -- Sub-workflow checkpoint validation tests -- + + +class SubStartExecutor(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + +class SubFinishExecutor(Executor): + @handler + async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(message) + + +def build_sub_workflow(sub_finish_id: str = "sub_finish"): + sub_start = SubStartExecutor(id="sub_start") + sub_finish = SubFinishExecutor(id=sub_finish_id) + return WorkflowBuilder(start_executor=sub_start).add_edge(sub_start, sub_finish).build() + + +def build_parent_workflow(storage: InMemoryCheckpointStorage, sub_finish_id: str = "sub_finish"): + sub_workflow = build_sub_workflow(sub_finish_id=sub_finish_id) + sub_executor = WorkflowExecutor(sub_workflow, id="sub_wf", allow_direct_output=True) + + start = StartExecutor(id="start") + finish = FinishExecutor(id="finish") + + builder = ( + WorkflowBuilder(max_iterations=3, start_executor=start, checkpoint_storage=storage) + .add_edge(start, sub_executor) + .add_edge(sub_executor, finish) + ) + return builder.build() + + +async def test_resume_succeeds_when_sub_workflow_matches() -> None: + storage = InMemoryCheckpointStorage() + workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + _ = [event async for event in workflow.run("hello", stream=True)] + + checkpoints = await storage.list_checkpoints() + assert checkpoints, "expected at least one checkpoint to be created" + target_checkpoint = checkpoints[-1] + + resumed_workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + events = [ + event + async for event in resumed_workflow.run( + checkpoint_id=target_checkpoint.checkpoint_id, + checkpoint_storage=storage, + stream=True, + ) + ] + + assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events) + + +async def test_resume_fails_when_sub_workflow_changes() -> None: + storage = InMemoryCheckpointStorage() + workflow = build_parent_workflow(storage, sub_finish_id="sub_finish") + + _ = [event async for event in workflow.run("hello", stream=True)] + + checkpoints = await storage.list_checkpoints() + assert checkpoints, "expected at least one checkpoint to be created" + target_checkpoint = checkpoints[-1] + + # Build parent with a structurally different sub-workflow (different executor id inside) + mismatched_workflow = build_parent_workflow(storage, sub_finish_id="sub_finish_alt") + + with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"): + _ = [ + event + async for event in mismatched_workflow.run( + checkpoint_id=target_checkpoint.checkpoint_id, + checkpoint_storage=storage, + stream=True, + ) + ] From 7e7d72275df49fdc49ab8f91d247daf936b76080 Mon Sep 17 00:00:00 2001 From: Rishabh Chawla Date: Tue, 10 Feb 2026 11:19:14 -0800 Subject: [PATCH 06/10] Python: [Purview] Update CorrelationId (#3745) --- .../Common/ProcessContentMetadataBase.cs | 6 +- .../Common/ProcessConversationMetadata.cs | 2 +- .../Models/Common/ProcessFileMetadata.cs | 2 +- .../PurviewSettings.cs | 2 +- .../PurviewWrapper.cs | 25 ++++- .../ScopedContentProcessor.cs | 7 +- .../PurviewClientTests.cs | 2 +- .../agent_framework_purview/_middleware.py | 34 ++++++- .../agent_framework_purview/_processor.py | 26 +++-- .../agent_framework_purview/_settings.py | 2 +- .../purview/tests/test_chat_middleware.py | 69 +++++++++++++- .../packages/purview/tests/test_middleware.py | 95 ++++++++++++++++++- .../packages/purview/tests/test_processor.py | 2 +- 13 files changed, 245 insertions(+), 29 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs index a401288127..ee9978fdf2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessContentMetadataBase.cs @@ -21,12 +21,14 @@ internal abstract class ProcessContentMetadataBase : GraphDataTypeBase /// The unique identifier for the content. /// Indicates if the content is truncated. /// The name of the content. - protected ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name) : base(ProcessConversationMetadataDataType) + /// The correlation ID for the content. + protected ProcessContentMetadataBase(ContentBase content, string identifier, bool isTruncated, string name, string correlationId) : base(ProcessConversationMetadataDataType) { this.Identifier = identifier; this.IsTruncated = isTruncated; this.Content = content; this.Name = name; + this.CorrelationId = correlationId; } /// @@ -55,7 +57,7 @@ internal abstract class ProcessContentMetadataBase : GraphDataTypeBase /// Identifier to group multiple contents. /// [JsonPropertyName("correlationId")] - public string? CorrelationId { get; set; } + public string CorrelationId { get; set; } /// /// Gets or sets the sequenceNumber. diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs index 86bedb9248..9100eac02e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessConversationMetadata.cs @@ -15,7 +15,7 @@ internal sealed class ProcessConversationMetadata : ProcessContentMetadataBase /// /// Initializes a new instance of the class. /// - public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + public ProcessConversationMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name, string correlationId) : base(contentBase, identifier, isTruncated, name, correlationId) { this.DataType = ProcessConversationMetadataDataType; } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs index a9f1749bed..89c0912e09 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/ProcessFileMetadata.cs @@ -14,7 +14,7 @@ internal sealed class ProcessFileMetadata : ProcessContentMetadataBase /// /// Initializes a new instance of the class. /// - public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name) : base(contentBase, identifier, isTruncated, name) + public ProcessFileMetadata(ContentBase contentBase, string identifier, bool isTruncated, string name, string correlationId) : base(contentBase, identifier, isTruncated, name, correlationId) { this.DataType = ProcessFileMetadataDataType; } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs index cb400805c6..508f531bbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewSettings.cs @@ -19,7 +19,7 @@ public class PurviewSettings /// The publicly visible name of the application. public PurviewSettings(string appName) { - this.AppName = appName; + this.AppName = string.IsNullOrWhiteSpace(appName) ? throw new ArgumentException("AppName cannot be null or whitespace.", nameof(appName)) : appName; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs index 5a63448478..9b6cdc2ffd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -53,7 +53,7 @@ internal sealed class PurviewWrapper : IDisposable } } - return Guid.NewGuid().ToString(); + return string.Empty; } /// @@ -136,12 +136,15 @@ internal sealed class PurviewWrapper : IDisposable /// The agent's response. This could be the response from the agent or a message indicating that Purview has blocked the prompt or response. public async Task ProcessAgentContentAsync(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) { - string sessionId = GetSessionIdFromAgentSession(session, messages); - string? resolvedUserId = null; - + string sessionId = string.Empty; try { + sessionId = GetSessionIdFromAgentSession(session, messages); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + } (bool shouldBlockPrompt, resolvedUserId) = await this._scopedProcessor.ProcessMessagesAsync(messages, sessionId, Activity.UploadText, this._purviewSettings, null, cancellationToken).ConfigureAwait(false); if (shouldBlockPrompt) @@ -171,7 +174,19 @@ internal sealed class PurviewWrapper : IDisposable try { - (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + string sessionIdResponse = GetSessionIdFromAgentSession(session, messages); + if (string.IsNullOrEmpty(sessionIdResponse)) + { + if (string.IsNullOrEmpty(sessionId)) + { + sessionIdResponse = Guid.NewGuid().ToString(); + } + else + { + sessionIdResponse = sessionId; + } + } + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); if (shouldBlockResponse) { diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 177b1a07d8..3fb7aa6c4d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -121,9 +121,10 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor { string messageId = message.MessageId ?? Guid.NewGuid().ToString(); ContentBase content = new PurviewTextContent(message.Text); - ProcessConversationMetadata conversationmetadata = new(content, messageId, false, $"Agent Framework Message {messageId}") + string correlationId = (sessionId ?? Guid.NewGuid().ToString()) + "@AF"; + ProcessConversationMetadata conversationMetadata = new(content, messageId, false, $"Agent Framework Message {messageId}", correlationId) { - CorrelationId = sessionId ?? Guid.NewGuid().ToString() + SequenceNumber = DateTime.UtcNow.Ticks, }; ActivityMetadata activityMetadata = new(activity); PolicyLocation policyLocation; @@ -162,7 +163,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor OperatingSystemVersion = "Unknown" } }; - ContentToProcess contentToProcess = new([conversationmetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); + ContentToProcess contentToProcess = new([conversationMetadata], activityMetadata, deviceMetadata, integratedAppMetadata, protectedAppMetadata); if (userId == null && tokenInfo?.UserId != null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index 0846decc2f..38abc903d3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -478,7 +478,7 @@ public sealed class PurviewClientTests : IDisposable private static ContentToProcess CreateValidContentToProcess() { var content = new PurviewTextContent("Test content"); - var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message"); + var metadata = new ProcessConversationMetadata(content, "msg-123", false, "Test message", "test-correlation-id"); var activityMetadata = new ActivityMetadata(Activity.UploadText); var deviceMetadata = new DeviceMetadata { diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index 42f8b37df6..52a74ffc10 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -45,6 +45,25 @@ class PurviewPolicyMiddleware(AgentMiddleware): self._processor = ScopedContentProcessor(self._client, settings, cache_provider) self._settings = settings + @staticmethod + def _get_agent_session_id(context: AgentContext) -> str | None: + """Resolve a session/conversation id from the agent run context. + + Resolution order: + 1. thread.service_thread_id + 2. First message whose additional_properties contains 'conversation_id' + 3. None: the downstream processor will generate a new UUID + """ + if context.thread and context.thread.service_thread_id: + return context.thread.service_thread_id + + for message in context.messages: + conversation_id = message.additional_properties.get("conversation_id") + if conversation_id is not None: + return str(conversation_id) + + return None + async def process( self, context: AgentContext, @@ -53,8 +72,9 @@ class PurviewPolicyMiddleware(AgentMiddleware): resolved_user_id: str | None = None try: # Pre (prompt) check + session_id = self._get_agent_session_id(context) should_block_prompt, resolved_user_id = await self._processor.process_messages( - context.messages, Activity.UPLOAD_TEXT + context.messages, Activity.UPLOAD_TEXT, session_id=session_id ) if should_block_prompt: from agent_framework import AgentResponse, ChatMessage @@ -79,10 +99,14 @@ class PurviewPolicyMiddleware(AgentMiddleware): try: # Post (response) check only if we have a normal AgentResponse # Use the same user_id from the request for the response evaluation + session_id_response = self._get_agent_session_id(context) + if session_id_response is None: + session_id_response = session_id if context.result and not context.stream: should_block_response, _ = await self._processor.process_messages( context.result.messages, # type: ignore[union-attr] Activity.UPLOAD_TEXT, + session_id=session_id, user_id=resolved_user_id, ) if should_block_response: @@ -144,8 +168,9 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): ) -> None: # type: ignore[override] resolved_user_id: str | None = None try: + session_id = context.options.get("conversation_id") if context.options else None should_block_prompt, resolved_user_id = await self._processor.process_messages( - context.messages, Activity.UPLOAD_TEXT + context.messages, Activity.UPLOAD_TEXT, session_id=session_id ) if should_block_prompt: from agent_framework import ChatMessage, ChatResponse @@ -169,12 +194,15 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): try: # Post (response) evaluation only if non-streaming and we have messages result shape # Use the same user_id from the request for the response evaluation + session_id_response = context.options.get("conversation_id") if context.options else None + if session_id_response is None: + session_id_response = session_id if context.result and not context.stream: result_obj = context.result messages = getattr(result_obj, "messages", None) if messages: should_block_response, _ = await self._processor.process_messages( - messages, Activity.UPLOAD_TEXT, user_id=resolved_user_id + messages, Activity.UPLOAD_TEXT, session_id=session_id_response, user_id=resolved_user_id ) if should_block_response: from agent_framework import ChatMessage, ChatResponse diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index fb115783f5..e2206a781b 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import time import uuid from collections.abc import Iterable, MutableMapping from typing import Any @@ -62,13 +63,18 @@ class ScopedContentProcessor: self._background_tasks: set[asyncio.Task[Any]] = set() async def process_messages( - self, messages: Iterable[ChatMessage], activity: Activity, user_id: str | None = None + self, + messages: Iterable[ChatMessage], + activity: Activity, + session_id: str | None = None, + user_id: str | None = None, ) -> tuple[bool, str | None]: """Process messages for policy evaluation. Args: messages: The messages to process activity: The activity type (e.g., UPLOAD_TEXT) + session_id: Optional session/conversation id. Else, a new GUID is generated. user_id: Optional user_id to use for all messages. If provided, this is the fallback. Returns: @@ -76,7 +82,7 @@ class ScopedContentProcessor: The resolved_user_id can be stored and passed back when processing the response to ensure the same user context is maintained throughout the request/response cycle. """ - pc_requests, resolved_user_id = await self._map_messages(messages, activity, user_id) + pc_requests, resolved_user_id = await self._map_messages(messages, activity, session_id, user_id) should_block = False for req in pc_requests: resp = await self._process_with_scopes(req) @@ -90,13 +96,18 @@ class ScopedContentProcessor: return should_block, resolved_user_id async def _map_messages( - self, messages: Iterable[ChatMessage], activity: Activity, provided_user_id: str | None = None + self, + messages: Iterable[ChatMessage], + activity: Activity, + session_id: str | None = None, + provided_user_id: str | None = None, ) -> tuple[list[ProcessContentRequest], str | None]: """Map messages to ProcessContentRequests. Args: messages: The messages to map activity: The activity type + session_id: Optional session/conversation id to use for correlation provided_user_id: Optional user_id to use. If provided, this is the fallback. Returns: @@ -137,12 +148,14 @@ class ScopedContentProcessor: for m in messages: message_id = m.message_id or str(uuid.uuid4()) content = PurviewTextContent(data=m.text or "") + correlation_id = (session_id or str(uuid.uuid4())) + "@AF" meta = ProcessConversationMetadata( identifier=message_id, content=content, name=f"Agent Framework Message {message_id}", is_truncated=False, - correlation_id=str(uuid.uuid4()), + correlation_id=correlation_id, + sequence_number=time.time_ns(), ) activity_meta = ActivityMetadata(activity=activity) @@ -159,12 +172,13 @@ class ScopedContentProcessor: else: raise ValueError("App location not provided or inferable") + app_version = self._settings.app_version or "Unknown" protected_app = ProtectedAppMetadata( name=self._settings.app_name, - version="1.0", + version=app_version, application_location=policy_location, ) - integrated_app = IntegratedAppMetadata(name=self._settings.app_name, version="1.0") + integrated_app = IntegratedAppMetadata(name=self._settings.app_name, version=app_version) device_meta = DeviceMetadata( operating_system_specifications=OperatingSystemSpecifications( operating_system_platform="Unknown", operating_system_version="Unknown" diff --git a/python/packages/purview/agent_framework_purview/_settings.py b/python/packages/purview/agent_framework_purview/_settings.py index 529b1399aa..3710d9de52 100644 --- a/python/packages/purview/agent_framework_purview/_settings.py +++ b/python/packages/purview/agent_framework_purview/_settings.py @@ -35,7 +35,7 @@ class PurviewAppLocation(BaseModel): class PurviewSettings(AFBaseSettings): - """Settings for Purview integration mirroring .NET PurviewSettings. + """Settings for Purview integration. Attributes: app_name: Public app name. diff --git a/python/packages/purview/tests/test_chat_middleware.py b/python/packages/purview/tests/test_chat_middleware.py index d42c5a85a9..41ed8e0e4e 100644 --- a/python/packages/purview/tests/test_chat_middleware.py +++ b/python/packages/purview/tests/test_chat_middleware.py @@ -9,6 +9,7 @@ from agent_framework import ChatContext, ChatMessage, MiddlewareTermination from azure.core.credentials import AccessToken from agent_framework_purview import PurviewChatPolicyMiddleware, PurviewSettings +from agent_framework_purview._models import Activity @dataclass @@ -82,7 +83,7 @@ class TestPurviewChatPolicyMiddleware: async def test_blocks_response(self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext) -> None: call_state = {"count": 0} - async def side_effect(messages, activity, user_id=None): + async def side_effect(messages, activity, session_id=None, user_id=None): call_state["count"] += 1 should_block = call_state["count"] == 2 return (should_block, "user-123") @@ -157,7 +158,7 @@ class TestPurviewChatPolicyMiddleware: """Test that the same user_id from pre-check is used in post-check.""" captured_user_ids = [] - async def mock_process_messages(messages, activity, user_id=None): + async def mock_process_messages(messages, activity, session_id=None, user_id=None): captured_user_ids.append(user_id) return (False, "resolved-user-123") @@ -362,3 +363,67 @@ class TestPurviewChatPolicyMiddleware: with pytest.raises(ValueError, match="post"): await middleware.process(context, mock_next) + + async def test_chat_middleware_uses_conversation_id_from_options( + self, middleware: PurviewChatPolicyMiddleware + ) -> None: + """Test that session_id is extracted from context.options['conversation_id'].""" + chat_client = DummyChatClient() + messages = [ChatMessage(role="user", text="Hello")] + options = {"conversation_id": "conv-123", "model": "test-model"} + context = ChatContext(chat_client=chat_client, messages=messages, options=options) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: ChatContext) -> None: + result = MagicMock() + result.messages = [ChatMessage(role="assistant", text="Hi")] + ctx.result = result + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="conv-123") + + async def test_chat_middleware_passes_none_session_id_when_options_missing( + self, middleware: PurviewChatPolicyMiddleware + ) -> None: + """Test that session_id is None when options don't contain conversation_id.""" + chat_client = DummyChatClient() + messages = [ChatMessage(role="user", text="Hello")] + context = ChatContext(chat_client=chat_client, messages=messages, options=None) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: ChatContext) -> None: + result = MagicMock() + result.messages = [ChatMessage(role="assistant", text="Hi")] + ctx.result = result + + await middleware.process(context, mock_next) + + # Verify session_id=None is passed + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id=None) + + async def test_chat_middleware_session_id_used_in_post_check(self, middleware: PurviewChatPolicyMiddleware) -> None: + """Test that session_id is passed to post-check process_messages call.""" + chat_client = DummyChatClient() + messages = [ChatMessage(role="user", text="Hello")] + options = {"conversation_id": "conv-999"} + context = ChatContext(chat_client=chat_client, messages=messages, options=options) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: ChatContext) -> None: + result = MagicMock() + result.messages = [ChatMessage(role="assistant", text="Response")] + ctx.result = result + + await middleware.process(context, mock_next) + + # Verify both calls include session_id + assert mock_proc.call_count == 2 + # Check post-check call includes session_id + post_check_call = mock_proc.call_args_list[1] + assert post_check_call[1]["session_id"] == "conv-999" diff --git a/python/packages/purview/tests/test_middleware.py b/python/packages/purview/tests/test_middleware.py index b0aadd8cd5..71eaa93056 100644 --- a/python/packages/purview/tests/test_middleware.py +++ b/python/packages/purview/tests/test_middleware.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentContext, AgentResponse, ChatMessage, MiddlewareTermination +from agent_framework import AgentContext, AgentResponse, AgentThread, ChatMessage, MiddlewareTermination from azure.core.credentials import AccessToken from agent_framework_purview import PurviewPolicyMiddleware, PurviewSettings +from agent_framework_purview._models import Activity class TestPurviewPolicyMiddleware: @@ -92,7 +93,7 @@ class TestPurviewPolicyMiddleware: call_count = 0 - async def mock_process_messages(messages, activity, user_id=None): + async def mock_process_messages(messages, activity, session_id=None, user_id=None): nonlocal call_count call_count += 1 should_block = call_count != 1 @@ -335,3 +336,93 @@ class TestPurviewPolicyMiddleware: # Should raise the exception with pytest.raises(ValueError, match="Test error"): await middleware.process(context, mock_next) + + async def test_middleware_uses_thread_service_thread_id_as_session_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is extracted from thread.service_thread_id.""" + thread = AgentThread(service_thread_id="thread-123") + context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")], thread=thread) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: AgentContext) -> None: + ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(context.messages, Activity.UPLOAD_TEXT, session_id="thread-123") + + async def test_middleware_uses_message_conversation_id_as_session_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is extracted from message.additional_properties['conversation_id'].""" + messages = [ChatMessage(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + context = AgentContext(agent=mock_agent, messages=messages) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: AgentContext) -> None: + ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id is passed to both pre-check and post-check + assert mock_proc.call_count == 2 + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="conv-456") + + async def test_middleware_thread_id_takes_precedence_over_message_conversation_id( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that thread.service_thread_id takes precedence over message conversation_id.""" + thread = AgentThread(service_thread_id="thread-789") + messages = [ChatMessage(role="user", text="Hello", additional_properties={"conversation_id": "conv-456"})] + context = AgentContext(agent=mock_agent, messages=messages, thread=thread) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: AgentContext) -> None: + ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify thread ID is used, not message conversation_id + mock_proc.assert_any_call(messages, Activity.UPLOAD_TEXT, session_id="thread-789") + + async def test_middleware_passes_none_session_id_when_not_available( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is None when no thread or conversation_id is available.""" + context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")]) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: AgentContext) -> None: + ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Hi")]) + + await middleware.process(context, mock_next) + + # Verify session_id=None is passed + mock_proc.assert_any_call(context.messages, Activity.UPLOAD_TEXT, session_id=None) + + async def test_middleware_session_id_used_in_post_check( + self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock + ) -> None: + """Test that session_id is passed to post-check process_messages call.""" + thread = AgentThread(service_thread_id="thread-999") + context = AgentContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")], thread=thread) + + with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc: + + async def mock_next(ctx: AgentContext) -> None: + ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")]) + + await middleware.process(context, mock_next) + + # Verify both calls include session_id + assert mock_proc.call_count == 2 + # Check post-check call includes session_id + post_check_call = mock_proc.call_args_list[1] + assert post_check_call[1]["session_id"] == "thread-999" diff --git a/python/packages/purview/tests/test_processor.py b/python/packages/purview/tests/test_processor.py index f122c6e059..be4d4aca89 100644 --- a/python/packages/purview/tests/test_processor.py +++ b/python/packages/purview/tests/test_processor.py @@ -92,7 +92,7 @@ class TestScopedContentProcessor: assert should_block is False assert user_id is None - mock_map.assert_called_once_with(messages, Activity.UPLOAD_TEXT, None) + mock_map.assert_called_once_with(messages, Activity.UPLOAD_TEXT, None, None) async def test_process_messages_blocks_content( self, processor: ScopedContentProcessor, process_content_request_factory From 7dccf3a07bc5fd76004b85305f5121712e188b15 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:21:18 +0000 Subject: [PATCH 07/10] .NET: [BREAKING] Update message source code to match python. (#3805) * Update message source code to match python. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comment * Move setting of source information to extension method * Add underscore for attribution key to indicate internal usage * Stick to version 102 of the SDK since 103 is causing issues. * Revert global.json change * Fix unit test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AIContextProvider.cs | 36 +- .../AgentRequestMessageSource.cs | 16 - .../AgentRequestMessageSourceAttribution.cs | 102 ++++ .../AgentRequestMessageSourceType.cs | 35 +- .../ChatHistoryProvider.cs | 34 +- .../ChatHistoryProviderExtensions.cs | 2 +- .../ChatMessageExtensions.cs | 60 ++- .../Microsoft.Agents.AI.Mem0/Mem0Provider.cs | 4 +- .../Memory/ChatHistoryMemoryProvider.cs | 4 +- .../Microsoft.Agents.AI/TextSearchProvider.cs | 4 +- .../AIContextProviderTests.cs | 45 +- ...entRequestMessageSourceAttributionTests.cs | 466 ++++++++++++++++++ .../AgentRequestMessageSourceTypeTests.cs | 103 +--- .../ChatHistoryProviderExtensionsTests.cs | 6 +- .../ChatHistoryProviderMessageFilterTests.cs | 4 +- .../ChatHistoryProviderTests.cs | 45 +- .../ChatMessageExtensionsTests.cs | 390 +++++++++++++-- .../InMemoryChatHistoryProviderTests.cs | 2 +- .../CosmosChatHistoryProviderTests.cs | 2 +- 19 files changed, 1084 insertions(+), 276 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs index a4b606e6a1..76ff8752b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs @@ -32,23 +32,23 @@ namespace Microsoft.Agents.AI; /// public abstract class AIContextProvider { - private readonly string _sourceName; + private readonly string _sourceId; /// /// Initializes a new instance of the class. /// protected AIContextProvider() { - this._sourceName = this.GetType().FullName!; + this._sourceId = this.GetType().FullName!; } /// - /// Initializes a new instance of the class with the specified source name. + /// Initializes a new instance of the class with the specified source id. /// - /// The source name to stamp on for each messages produced by the . - protected AIContextProvider(string sourceName) + /// The source id to stamp on for each messages produced by the . + protected AIContextProvider(string sourceId) { - this._sourceName = sourceName; + this._sourceId = sourceId; } /// @@ -76,27 +76,9 @@ public abstract class AIContextProvider return aiContext; } - aiContext.Messages = aiContext.Messages.Select(message => - { - if (message.AdditionalProperties != null - // Check if the message was already tagged with this provider's source type - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var messageSourceType) - && messageSourceType is AgentRequestMessageSourceType typedMessageSourceType - && typedMessageSourceType == AgentRequestMessageSourceType.AIContextProvider - // Check if the message was already tagged with this provider's source - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out var messageSource) - && messageSource is string typedMessageSource - && typedMessageSource == this._sourceName) - { - return message; - } - - message = message.Clone(); - message.AdditionalProperties ??= new(); - message.AdditionalProperties[AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.AIContextProvider; - message.AdditionalProperties[AgentRequestMessageSource.AdditionalPropertiesKey] = this._sourceName; - return message; - }).ToList(); + aiContext.Messages = aiContext.Messages + .Select(message => message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, this._sourceId)) + .ToList(); return aiContext; } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs deleted file mode 100644 index 127f1c1b8d..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSource.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI; - -/// -/// Provides a constant for the key used to store the source of the agent request message. -/// -public static class AgentRequestMessageSource -{ - /// - /// Provides the key used in to store the source of the agent request message. - /// - public static readonly string AdditionalPropertiesKey = "Agent.RequestMessageSource"; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs new file mode 100644 index 0000000000..2c606814ce --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceAttribution.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Represents attribution information for the source of an agent request message for a specific run, including the component type and +/// identifier. +/// +/// +/// Use this struct to identify which component provided a message during an agent run. +/// This is useful to allow filtering of messages based on their source, such as distinguishing between user input, middleware-generated messages, and chat history. +/// +public readonly struct AgentRequestMessageSourceAttribution : IEquatable +{ + /// + /// Provides the key used in to store the + /// associated with the agent request message. + /// + public static readonly string AdditionalPropertiesKey = "_attribution"; + + /// + /// Initializes a new instance of the struct with the specified source type and identifier. + /// + /// The of the component that provided the message. + /// The unique identifier of the component that provided the message. + public AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType sourceType, string? sourceId) + { + this.SourceType = sourceType; + this.SourceId = sourceId; + } + + /// + /// Gets the type of component that provided the message for the current agent run. + /// + public AgentRequestMessageSourceType SourceType { get; } + + /// + /// Gets the unique identifier of the component that provided the message for the current agent run. + /// + public string? SourceId { get; } + + /// + /// Determines whether the specified is equal to the current instance. + /// + /// The to compare with the current instance. + /// if the specified instance is equal to the current instance; otherwise, . + public bool Equals(AgentRequestMessageSourceAttribution other) + { + return this.SourceType == other.SourceType && + string.Equals(this.SourceId, other.SourceId, StringComparison.Ordinal); + } + + /// + /// Determines whether the specified object is equal to the current instance. + /// + /// The object to compare with the current instance. + /// if the specified object is equal to the current instance; otherwise, . + public override bool Equals(object? obj) + { + return obj is AgentRequestMessageSourceAttribution other && this.Equals(other); + } + + /// + /// Returns a hash code for the current instance. + /// + /// A hash code for the current instance. + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = (hash * 31) + this.SourceType.GetHashCode(); + hash = (hash * 31) + (this.SourceId?.GetHashCode() ?? 0); + return hash; + } + } + + /// + /// Determines whether two instances are equal. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are equal; otherwise, . + public static bool operator ==(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right) + { + return left.Equals(right); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are not equal; otherwise, . + public static bool operator !=(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right) + { + return !left.Equals(right); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs index 1cca747906..14bbbe3388 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRequestMessageSourceType.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -14,15 +13,10 @@ namespace Microsoft.Agents.AI; /// This type helps to identify whether a message came from outside the agent pipeline, /// whether it was produced by middleware, or came from chat history. /// -public sealed class AgentRequestMessageSourceType : IEquatable +public readonly struct AgentRequestMessageSourceType : IEquatable { /// - /// Provides the key used in to store the source type of the agent request message. - /// - public static readonly string AdditionalPropertiesKey = "Agent.RequestMessageSourceType"; - - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the struct. /// /// The string value representing the source of the agent request message. public AgentRequestMessageSourceType(string value) => this.Value = Throw.IfNullOrWhitespace(value); @@ -30,7 +24,7 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// Get the string value representing the source of the agent request message. /// - public string Value { get; } + public string Value { get { return field ?? External.Value; } } /// /// The message came from outside the agent pipeline (e.g., user input). @@ -52,18 +46,8 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// The to compare to this instance. /// if the value of the parameter is the same as the value of this instance; otherwise, . - public bool Equals(AgentRequestMessageSourceType? other) + public bool Equals(AgentRequestMessageSourceType other) { - if (other is null) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - return string.Equals(this.Value, other.Value, StringComparison.Ordinal); } @@ -72,7 +56,7 @@ public sealed class AgentRequestMessageSourceType : IEquatable /// The object to compare to this instance. /// if is a and its value is the same as this instance; otherwise, . - public override bool Equals(object? obj) => this.Equals(obj as AgentRequestMessageSourceType); + public override bool Equals(object? obj) => obj is AgentRequestMessageSourceType other && this.Equals(other); /// /// Returns the hash code for this instance. @@ -86,13 +70,8 @@ public sealed class AgentRequestMessageSourceType : IEquatableThe first to compare. /// The second to compare. /// if the value of is the same as the value of ; otherwise, . - public static bool operator ==(AgentRequestMessageSourceType? left, AgentRequestMessageSourceType? right) + public static bool operator ==(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right) { - if (left is null) - { - return right is null; - } - return left.Equals(right); } @@ -102,5 +81,5 @@ public sealed class AgentRequestMessageSourceType : IEquatableThe first to compare. /// The second to compare. /// if the value of is different from the value of ; otherwise, . - public static bool operator !=(AgentRequestMessageSourceType? left, AgentRequestMessageSourceType? right) => !(left == right); + public static bool operator !=(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right) => !(left == right); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index f49c5d46a7..086ad02061 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -37,23 +37,23 @@ namespace Microsoft.Agents.AI; /// public abstract class ChatHistoryProvider { - private readonly string _sourceName; + private readonly string _sourceId; /// /// Initializes a new instance of the class. /// protected ChatHistoryProvider() { - this._sourceName = this.GetType().FullName!; + this._sourceId = this.GetType().FullName!; } /// - /// Initializes a new instance of the class with the specified source name. + /// Initializes a new instance of the class with the specified source id. /// - /// The source name to stamp on for each messages produced by the . - protected ChatHistoryProvider(string sourceName) + /// The source id to stamp on for each messages produced by the . + protected ChatHistoryProvider(string sourceId) { - this._sourceName = sourceName; + this._sourceId = sourceId; } /// @@ -89,27 +89,7 @@ public abstract class ChatHistoryProvider { var messages = await this.InvokingCoreAsync(context, cancellationToken).ConfigureAwait(false); - return messages.Select(message => - { - if (message.AdditionalProperties != null - // Check if the message was already tagged with this provider's source type - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var messageSourceType) - && messageSourceType is AgentRequestMessageSourceType typedMessageSourceType - && typedMessageSourceType == AgentRequestMessageSourceType.ChatHistory - // Check if the message was already tagged with this provider's source - && message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out var messageSource) - && messageSource is string typedMessageSource - && typedMessageSource == this._sourceName) - { - return message; - } - - message = message.Clone(); - message.AdditionalProperties ??= new(); - message.AdditionalProperties[AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.ChatHistory; - message.AdditionalProperties[AgentRequestMessageSource.AdditionalPropertiesKey] = this._sourceName; - return message; - }); + return messages.Select(message => message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, this._sourceId)); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs index c2ff8bf3e5..4c8ef2489f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProviderExtensions.cs @@ -45,7 +45,7 @@ public static class ChatHistoryProviderExtensions innerProvider: provider, invokedMessagesFilter: (ctx) => { - ctx.RequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSource() != AgentRequestMessageSourceType.AIContextProvider); + ctx.RequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider); return ctx; }); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs index 01edcb4eff..052e48ce56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageExtensions.cs @@ -10,18 +10,66 @@ namespace Microsoft.Agents.AI; public static class ChatMessageExtensions { /// - /// Gets the source of the provided in the context of messages passed into an agent run. + /// Gets the source type of the provided in the context of messages passed into an agent run. /// - /// The for which we need the source. - /// An value indicating the source of the . Defaults to The for which we need the source type. + /// An value indicating the source type of the . Defaults to if no explicit source is defined. - public static AgentRequestMessageSourceType GetAgentRequestMessageSource(this ChatMessage message) + public static AgentRequestMessageSourceType GetAgentRequestMessageSourceType(this ChatMessage message) { - if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out var source) is true && source is AgentRequestMessageSourceType typedSource) + if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true + && attribution is AgentRequestMessageSourceAttribution typedAttribution) { - return typedSource; + return typedAttribution.SourceType; } return AgentRequestMessageSourceType.External; } + + /// + /// Gets the source id of the provided in the context of messages passed into an agent run. + /// + /// The for which we need the source id. + /// An value indicating the source id of the . Defaults to + /// if no explicit source id is defined. + public static string? GetAgentRequestMessageSourceId(this ChatMessage message) + { + if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true + && attribution is AgentRequestMessageSourceAttribution typedAttribution) + { + return typedAttribution.SourceId; + } + + return null; + } + + /// + /// Ensure that the provided message is tagged with the provided source type and source id in the context of a specific agent run. + /// + /// The message to tag. + /// The source type to tag the message with. + /// The source id to tag the message with. + /// The tagged message. + /// + /// If the message is already tagged with the provided source type and source id, it is returned as is. + /// Otherwise, a cloned message is returned with the appropriate tagging in the AdditionalProperties. + /// + public static ChatMessage AsAgentRequestMessageSourcedMessage(this ChatMessage message, AgentRequestMessageSourceType sourceType, string? sourceId = null) + { + if (message.AdditionalProperties != null + // Check if the message was already tagged with the required source type and source id + && message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var messageSourceAttribution) + && messageSourceAttribution is AgentRequestMessageSourceAttribution typedMessageSourceAttribution + && typedMessageSourceAttribution.SourceType == sourceType + && typedMessageSourceAttribution.SourceId == sourceId) + { + return message; + } + + message = message.Clone(); + message.AdditionalProperties ??= new(); + message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = + new AgentRequestMessageSourceAttribution(sourceType, sourceId); + return message; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index 8a0c016f07..a230eb0e4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -138,7 +138,7 @@ public sealed class Mem0Provider : AIContextProvider string queryText = string.Join( Environment.NewLine, context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(m => !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); @@ -217,7 +217,7 @@ public sealed class Mem0Provider : AIContextProvider // Persist request and response messages after invocation. await this.PersistMessagesAsync( context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index c63e8ac682..b8e495152c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -189,7 +189,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable { // Get the text from the current request messages var requestText = string.Join("\n", context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(m => m != null && !string.IsNullOrWhiteSpace(m.Text)) .Select(m => m.Text)); @@ -245,7 +245,7 @@ public sealed class ChatHistoryMemoryProvider : AIContextProvider, IDisposable var collection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); List> itemsToStore = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []) .Select(message => new Dictionary { diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index ee87d4f00c..f29aadf808 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -118,7 +118,7 @@ public sealed class TextSearchProvider : AIContextProvider // Aggregate text from memory + current request messages. var sbInput = new StringBuilder(); var requestMessagesText = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text); foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText)) { @@ -182,7 +182,7 @@ public sealed class TextSearchProvider : AIContextProvider } var messagesText = context.RequestMessages - .Where(m => m.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External) + .Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External) .Concat(context.ResponseMessages ?? []) .Where(m => this._recentMessageRolesIncluded.Contains(m.Role) && diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs index 44d1be2e74..aa41a03efc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIContextProviderTests.cs @@ -19,7 +19,7 @@ public class AIContextProviderTests #region InvokingAsync Message Stamping Tests [Fact] - public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceAsync() + public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceIdAsync() { // Arrange var provider = new TestAIContextProviderWithMessages(); @@ -32,18 +32,18 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithMessages).FullName, typedAttribution.SourceId); } [Fact] - public async Task InvokingAsync_WithCustomSourceName_StampsMessagesWithCustomSourceAsync() + public async Task InvokingAsync_WithCustomSourceId_StampsMessagesWithCustomSourceIdAsync() { // Arrange - const string CustomSourceName = "CustomContextSource"; - var provider = new TestAIContextProviderWithCustomSource(CustomSourceName); + const string CustomSourceId = "CustomContextSource"; + var provider = new TestAIContextProviderWithCustomSource(CustomSourceId); var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Request")]); // Act @@ -53,10 +53,10 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(CustomSourceName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(CustomSourceId, typedAttribution.SourceId); } [Fact] @@ -73,10 +73,10 @@ public class AIContextProviderTests Assert.NotNull(aiContext.Messages); ChatMessage message = aiContext.Messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithPreStampedMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithPreStampedMessages).FullName, typedAttribution.SourceId); } [Fact] @@ -97,10 +97,10 @@ public class AIContextProviderTests foreach (ChatMessage message in messageList) { Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestAIContextProviderWithMultipleMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, typedAttribution.SourceType); + Assert.Equal(typeof(TestAIContextProviderWithMultipleMessages).FullName, typedAttribution.SourceId); } } @@ -486,7 +486,7 @@ public class AIContextProviderTests private sealed class TestAIContextProviderWithCustomSource : AIContextProvider { - public TestAIContextProviderWithCustomSource(string sourceName) : base(sourceName) + public TestAIContextProviderWithCustomSource(string sourceId) : base(sourceId) { } @@ -504,8 +504,7 @@ public class AIContextProviderTests var message = new ChatMessage(ChatRole.System, "Pre-stamped Message"); message.AdditionalProperties = new AdditionalPropertiesDictionary { - [AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.AIContextProvider, - [AgentRequestMessageSource.AdditionalPropertiesKey] = this.GetType().FullName! + [AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!) }; return new(new AIContext { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs new file mode 100644 index 0000000000..5aee121097 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceAttributionTests.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Contains tests for the struct. +/// +public sealed class AgentRequestMessageSourceAttributionTests +{ + #region Constructor Tests + + [Fact] + public void Constructor_SetsSourceTypeAndSourceId() + { + // Arrange + AgentRequestMessageSourceType expectedType = AgentRequestMessageSourceType.AIContextProvider; + const string ExpectedId = "MyProvider"; + + // Act + AgentRequestMessageSourceAttribution attribution = new(expectedType, ExpectedId); + + // Assert + Assert.Equal(expectedType, attribution.SourceType); + Assert.Equal(ExpectedId, attribution.SourceId); + } + + [Fact] + public void Constructor_WithNullSourceId_SetsNullSourceId() + { + // Arrange + AgentRequestMessageSourceType sourceType = AgentRequestMessageSourceType.ChatHistory; + + // Act + AgentRequestMessageSourceAttribution attribution = new(sourceType, null); + + // Assert + Assert.Equal(sourceType, attribution.SourceType); + Assert.Null(attribution.SourceId); + } + + #endregion + + #region AdditionalPropertiesKey Tests + + [Fact] + public void AdditionalPropertiesKey_IsAttribution() + { + // Assert + Assert.Equal("_attribution", AgentRequestMessageSourceAttribution.AdditionalPropertiesKey); + } + + #endregion + + #region Default Value Tests + + [Fact] + public void Default_HasDefaultSourceTypeAndNullSourceId() + { + // Arrange & Act + AgentRequestMessageSourceAttribution attribution = default; + + // Assert + Assert.Equal(default, attribution.SourceType); + Assert.Null(attribution.SourceId); + } + + #endregion + + #region Equals (IEquatable) Tests + + [Fact] + public void Equals_WithSameSourceTypeAndSourceId_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithDifferentSourceType_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider1"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentSourceTypeAndSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_WithDifferentCaseSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "provider"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + [Fact] + public void Equals_BothDefaultValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithBothNullSourceIds_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, null!); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, null!); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void Equals_WithOneNullSourceId_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, null!); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + #endregion + + #region Object.Equals Tests + + [Fact] + public void ObjectEquals_WithEqualAttribution_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object attribution2 = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "Provider"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.True(result); + } + + [Fact] + public void ObjectEquals_WithDifferentType_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object other = "NotAnAttribution"; + + // Act + bool result = attribution.Equals(other); + + // Assert + Assert.False(result); + } + + [Fact] + public void ObjectEquals_WithNullObject_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + object? other = null; + + // Act + bool result = attribution.Equals(other); + + // Assert + Assert.False(result); + } + + [Fact] + public void ObjectEquals_WithBoxedDifferentAttribution_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.ChatHistory, "Provider1"); + object attribution2 = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1.Equals(attribution2); + + // Assert + Assert.False(result); + } + + #endregion + + #region GetHashCode Tests + + [Fact] + public void GetHashCode_WithSameValues_ReturnsSameHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithDifferentSourceType_ReturnsDifferentHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithDifferentSourceId_ReturnsDifferentHashCode() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + int hashCode1 = attribution1.GetHashCode(); + int hashCode2 = attribution2.GetHashCode(); + + // Assert + Assert.NotEqual(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_ConsistentWithEquals() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.External, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act & Assert + Assert.True(attribution1.Equals(attribution2)); + Assert.Equal(attribution1.GetHashCode(), attribution2.GetHashCode()); + } + + [Fact] + public void GetHashCode_WithNullSourceId_DoesNotThrow() + { + // Arrange + AgentRequestMessageSourceAttribution attribution = new(AgentRequestMessageSourceType.External, null!); + + // Act + int hashCode = attribution.GetHashCode(); + + // Assert + Assert.IsType(hashCode); + } + + #endregion + + #region Equality Operator Tests + + [Fact] + public void EqualityOperator_WithEqualValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void EqualityOperator_WithDifferentValues_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void EqualityOperator_WithBothDefault_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void EqualityOperator_WithDifferentSourceTypeOnly_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void EqualityOperator_WithDifferentSourceIdOnly_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1 == attribution2; + + // Assert + Assert.False(result); + } + + #endregion + + #region Inequality Operator Tests + + [Fact] + public void InequalityOperator_WithEqualValues_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void InequalityOperator_WithDifferentValues_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.ChatHistory, "Provider2"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void InequalityOperator_WithBothDefault_ReturnsFalse() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = default; + AgentRequestMessageSourceAttribution attribution2 = default; + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.False(result); + } + + [Fact] + public void InequalityOperator_WithDifferentSourceTypeOnly_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.External, "Provider"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + [Fact] + public void InequalityOperator_WithDifferentSourceIdOnly_ReturnsTrue() + { + // Arrange + AgentRequestMessageSourceAttribution attribution1 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider1"); + AgentRequestMessageSourceAttribution attribution2 = new(AgentRequestMessageSourceType.AIContextProvider, "Provider2"); + + // Act + bool result = attribution1 != attribution2; + + // Assert + Assert.True(result); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs index f6149092f3..000505fe32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRequestMessageSourceTypeTests.cs @@ -5,7 +5,7 @@ using System; namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// -/// Contains tests for the class. +/// Contains tests for the struct. /// public sealed class AgentRequestMessageSourceTypeTests { @@ -38,6 +38,16 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.Throws(() => new AgentRequestMessageSourceType(string.Empty)); } + [Fact] + public void Default_DefaultsToExternal() + { + // Act + AgentRequestMessageSourceType defaultSource = default; + + // Assert + Assert.Equal(AgentRequestMessageSourceType.External, defaultSource); + } + #endregion #region Static Properties Tests @@ -49,7 +59,6 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.External; // Assert - Assert.NotNull(source); Assert.Equal("External", source.Value); } @@ -60,7 +69,6 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.AIContextProvider; // Assert - Assert.NotNull(source); Assert.Equal("AIContextProvider", source.Value); } @@ -71,22 +79,11 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType source = AgentRequestMessageSourceType.ChatHistory; // Assert - Assert.NotNull(source); Assert.Equal("ChatHistory", source.Value); } [Fact] - public void AdditionalPropertiesKey_ReturnsExpectedValue() - { - // Arrange & Act - string key = AgentRequestMessageSourceType.AdditionalPropertiesKey; - - // Assert - Assert.Equal("Agent.RequestMessageSourceType", key); - } - - [Fact] - public void StaticProperties_ReturnSameInstanceOnMultipleCalls() + public void StaticProperties_ReturnEqualValuesOnMultipleCalls() { // Arrange & Act AgentRequestMessageSourceType external1 = AgentRequestMessageSourceType.External; @@ -97,9 +94,9 @@ public sealed class AgentRequestMessageSourceTypeTests AgentRequestMessageSourceType chatHistory2 = AgentRequestMessageSourceType.ChatHistory; // Assert - Assert.Same(external1, external2); - Assert.Same(aiContextProvider1, aiContextProvider2); - Assert.Same(chatHistory1, chatHistory2); + Assert.Equal(external1, external2); + Assert.Equal(aiContextProvider1, aiContextProvider2); + Assert.Equal(chatHistory1, chatHistory2); } #endregion @@ -148,7 +145,7 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void Equals_WithNull_ReturnsFalse() + public void Equals_WithNullObject_ReturnsFalse() { // Arrange AgentRequestMessageSourceType source = new("Test"); @@ -314,11 +311,11 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void EqualityOperator_WithBothNull_ReturnsTrue() + public void EqualityOperator_WithDefaultValues_ReturnsTrue() { // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType? source2 = null; + AgentRequestMessageSourceType source1 = default; + AgentRequestMessageSourceType source2 = default; // Act bool result = source1 == source2; @@ -327,34 +324,6 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.True(result); } - [Fact] - public void EqualityOperator_WithLeftNull_ReturnsFalse() - { - // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType source2 = new("Test"); - - // Act - bool result = source1 == source2; - - // Assert - Assert.False(result); - } - - [Fact] - public void EqualityOperator_WithRightNull_ReturnsFalse() - { - // Arrange - AgentRequestMessageSourceType source1 = new("Test"); - AgentRequestMessageSourceType? source2 = null; - - // Act - bool result = source1 == source2; - - // Assert - Assert.False(result); - } - [Fact] public void EqualityOperator_WithStaticInstances_ReturnsTrue() { @@ -416,11 +385,11 @@ public sealed class AgentRequestMessageSourceTypeTests } [Fact] - public void InequalityOperator_WithBothNull_ReturnsFalse() + public void InequalityOperator_WithBothDefault_ReturnsFalse() { // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType? source2 = null; + AgentRequestMessageSourceType source1 = default; + AgentRequestMessageSourceType source2 = default; // Act bool result = source1 != source2; @@ -429,34 +398,6 @@ public sealed class AgentRequestMessageSourceTypeTests Assert.False(result); } - [Fact] - public void InequalityOperator_WithLeftNull_ReturnsTrue() - { - // Arrange - AgentRequestMessageSourceType? source1 = null; - AgentRequestMessageSourceType source2 = new("Test"); - - // Act - bool result = source1 != source2; - - // Assert - Assert.True(result); - } - - [Fact] - public void InequalityOperator_WithRightNull_ReturnsTrue() - { - // Arrange - AgentRequestMessageSourceType source1 = new("Test"); - AgentRequestMessageSourceType? source2 = null; - - // Act - bool result = source1 != source2; - - // Assert - Assert.True(result); - } - [Fact] public void InequalityOperator_DifferentStaticInstances_ReturnsTrue() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs index 1244209a97..1fcbe37e25 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderExtensionsTests.cs @@ -64,7 +64,7 @@ public sealed class ChatHistoryProviderExtensionsTests Mock providerMock = new(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello") ]; ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages) @@ -114,9 +114,9 @@ public sealed class ChatHistoryProviderExtensionsTests Mock providerMock = new(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello"), - new(ChatRole.System, "Context") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } } } + new(ChatRole.System, "Context") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestContextSource") } } } ]; ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs index 5b48d025be..75d8f554c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderMessageFilterTests.cs @@ -157,7 +157,7 @@ public sealed class ChatHistoryProviderMessageFilterTests var innerProviderMock = new Mock(); List requestMessages = [ - new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "System") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, new(ChatRole.User, "Hello"), ]; var responseMessages = new List { new(ChatRole.Assistant, "Response") }; @@ -176,7 +176,7 @@ public sealed class ChatHistoryProviderMessageFilterTests // Filter that modifies the context ChatHistoryProvider.InvokedContext InvokedFilter(ChatHistoryProvider.InvokedContext ctx) { - var modifiedRequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSource() == AgentRequestMessageSourceType.External).Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList(); + var modifiedRequestMessages = ctx.RequestMessages.Where(x => x.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External).Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList(); return new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, modifiedRequestMessages) { ResponseMessages = ctx.ResponseMessages, diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs index e158b159ca..8b07366b03 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatHistoryProviderTests.cs @@ -22,7 +22,7 @@ public class ChatHistoryProviderTests #region InvokingAsync Message Stamping Tests [Fact] - public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceAsync() + public async Task InvokingAsync_StampsMessagesWithSourceTypeAndSourceIdAsync() { // Arrange var provider = new TestChatHistoryProvider(); @@ -34,18 +34,18 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProvider).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProvider).FullName, typedAttribution.SourceId); } [Fact] - public async Task InvokingAsync_WithCustomSourceName_StampsMessagesWithCustomSourceAsync() + public async Task InvokingAsync_WithCustomSourceId_StampsMessagesWithCustomSourceIdAsync() { // Arrange - const string CustomSourceName = "CustomHistorySource"; - var provider = new TestChatHistoryProviderWithCustomSource(CustomSourceName); + const string CustomSourceId = "CustomHistorySource"; + var provider = new TestChatHistoryProviderWithCustomSource(CustomSourceId); var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Request")]); // Act @@ -54,10 +54,10 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(CustomSourceName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(CustomSourceId, typedAttribution.SourceId); } [Fact] @@ -73,10 +73,10 @@ public class ChatHistoryProviderTests // Assert ChatMessage message = messages.Single(); Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProviderWithPreStampedMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProviderWithPreStampedMessages).FullName, typedAttribution.SourceId); } [Fact] @@ -96,10 +96,10 @@ public class ChatHistoryProviderTests foreach (ChatMessage message in messageList) { Assert.NotNull(message.AdditionalProperties); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceType.AdditionalPropertiesKey, out object? sourceType)); - Assert.Equal(AgentRequestMessageSourceType.ChatHistory, sourceType); - Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSource.AdditionalPropertiesKey, out object? source)); - Assert.Equal(typeof(TestChatHistoryProviderWithMultipleMessages).FullName, source); + Assert.True(message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out object? attribution)); + var typedAttribution = Assert.IsType(attribution); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, typedAttribution.SourceType); + Assert.Equal(typeof(TestChatHistoryProviderWithMultipleMessages).FullName, typedAttribution.SourceId); } } @@ -383,7 +383,7 @@ public class ChatHistoryProviderTests private sealed class TestChatHistoryProviderWithCustomSource : ChatHistoryProvider { - public TestChatHistoryProviderWithCustomSource(string sourceName) : base(sourceName) + public TestChatHistoryProviderWithCustomSource(string sourceId) : base(sourceId) { } @@ -404,8 +404,7 @@ public class ChatHistoryProviderTests var message = new ChatMessage(ChatRole.User, "Pre-stamped Message"); message.AdditionalProperties = new AdditionalPropertiesDictionary { - [AgentRequestMessageSourceType.AdditionalPropertiesKey] = AgentRequestMessageSourceType.ChatHistory, - [AgentRequestMessageSource.AdditionalPropertiesKey] = this.GetType().FullName! + [AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] = new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!) }; return new([message]); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs index f389c567d2..97050c3071 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/ChatMessageExtensionsTests.cs @@ -9,23 +9,23 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; /// public sealed class ChatMessageExtensionsTests { - #region GetAgentRequestMessageSource Tests + #region GetAgentRequestMessageSourceType Tests [Fact] - public void GetAgentRequestMessageSource_WithNoAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNoAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello"); // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithNullAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNullAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -34,14 +34,14 @@ public sealed class ChatMessageExtensionsTests }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithEmptyAdditionalProperties_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithEmptyAdditionalProperties_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -50,130 +50,130 @@ public sealed class ChatMessageExtensionsTests }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithExternalSource_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithExternalSourceType_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.External } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithAIContextProviderSource_ReturnsAIContextProvider() + public void GetAgentRequestMessageSourceType_WithAIContextProviderSourceType_ReturnsAIContextProvider() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result); } [Fact] - public void GetAgentRequestMessageSource_WithChatHistorySource_ReturnsChatHistory() + public void GetAgentRequestMessageSourceType_WithChatHistorySourceType_ReturnsChatHistory() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result); } [Fact] - public void GetAgentRequestMessageSource_WithCustomSource_ReturnsCustomSource() + public void GetAgentRequestMessageSourceType_WithCustomSourceType_ReturnsCustomSourceType() { // Arrange - AgentRequestMessageSourceType customSource = new("CustomSource"); + AgentRequestMessageSourceType customSourceType = new("CustomSourceType"); ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, customSource } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(customSourceType, "TestSourceId") } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert - Assert.Equal(customSource, result); - Assert.Equal("CustomSource", result.Value); + Assert.Equal(customSourceType, result); + Assert.Equal("CustomSourceType", result.Value); } [Fact] - public void GetAgentRequestMessageSource_WithWrongKeyType_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithWrongAttributionType_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, "NotAnAgentRequestMessageSource" } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAgentRequestMessageSourceAttribution" } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithNullValue_ReturnsExternal() + public void GetAgentRequestMessageSourceType_WithNullAttributionValue_ReturnsExternal() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") { AdditionalProperties = new AdditionalPropertiesDictionary { - { AgentRequestMessageSourceType.AdditionalPropertiesKey, null! } + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, null! } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.External, result); } [Fact] - public void GetAgentRequestMessageSource_WithMultipleProperties_ReturnsCorrectSource() + public void GetAgentRequestMessageSourceType_WithMultipleProperties_ReturnsCorrectSourceType() { // Arrange ChatMessage message = new(ChatRole.User, "Hello") @@ -181,17 +181,345 @@ public sealed class ChatMessageExtensionsTests AdditionalProperties = new AdditionalPropertiesDictionary { { "OtherProperty", "SomeValue" }, - { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory }, + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSourceId") }, { "AnotherProperty", 123 } } }; // Act - AgentRequestMessageSourceType result = message.GetAgentRequestMessageSource(); + AgentRequestMessageSourceType result = message.GetAgentRequestMessageSourceType(); // Assert Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result); } #endregion + + #region GetAgentRequestMessageSourceId Tests + + [Fact] + public void GetAgentRequestMessageSourceId_WithNoAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithNullAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = null + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithEmptyAdditionalProperties_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary() + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithAttribution_ReturnsSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "MyProvider.FullName") } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("MyProvider.FullName", result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithDifferentSourceIds_ReturnsCorrectSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "CustomHistorySourceId") } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("CustomHistorySourceId", result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithWrongAttributionType_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAgentRequestMessageSourceAttribution" } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithNullAttributionValue_ReturnsNull() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, null! } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetAgentRequestMessageSourceId_WithMultipleProperties_ReturnsCorrectSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "OtherProperty", "SomeValue" }, + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "ExpectedSourceId") }, + { "AnotherProperty", 123 } + } + }; + + // Act + string? result = message.GetAgentRequestMessageSourceId(); + + // Assert + Assert.Equal("ExpectedSourceId", result); + } + + #endregion + + #region AsAgentRequestMessageSourcedMessage Tests + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithNoAdditionalProperties_ReturnsClonesMessageWithAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "TestSourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("TestSourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithNullAdditionalProperties_ReturnsClonesMessageWithAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = null + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "ProviderSourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + Assert.Equal("ProviderSourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndSourceId_ReturnsSameInstance() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "HistoryId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, "HistoryId"); + + // Assert + Assert.Same(message, result); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDifferentSourceType_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "SourceId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "SourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + Assert.Equal("SourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDifferentSourceId_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, "OriginalId") } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "NewId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("NewId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithDefaultNullSourceId_ReturnsClonesMessageWithNullSourceId() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.ChatHistory, result.GetAgentRequestMessageSourceType()); + Assert.Null(result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndNullSourceId_ReturnsSameInstance() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.External, null) } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External); + + // Assert + Assert.Same(message, result); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_DoesNotModifyOriginalMessage() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.AIContextProvider, "ProviderId"); + + // Assert + Assert.Null(message.AdditionalProperties); + Assert.NotNull(result.AdditionalProperties); + Assert.Equal(AgentRequestMessageSourceType.AIContextProvider, result.GetAgentRequestMessageSourceType()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_WithWrongAttributionType_ReturnsClonesMessageWithNewAttribution() + { + // Arrange + ChatMessage message = new(ChatRole.User, "Hello") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, "NotAnAttribution" } + } + }; + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.External, "SourceId"); + + // Assert + Assert.NotSame(message, result); + Assert.Equal(AgentRequestMessageSourceType.External, result.GetAgentRequestMessageSourceType()); + Assert.Equal("SourceId", result.GetAgentRequestMessageSourceId()); + } + + [Fact] + public void AsAgentRequestMessageSourcedMessage_PreservesMessageContent() + { + // Arrange + ChatMessage message = new(ChatRole.Assistant, "Test content"); + + // Act + ChatMessage result = message.AsAgentRequestMessageSourcedMessage(AgentRequestMessageSourceType.ChatHistory, "HistoryId"); + + // Assert + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal("Test content", result.Text); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs index 75232073a6..cecce5bea1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/InMemoryChatHistoryProviderTests.cs @@ -55,7 +55,7 @@ public class InMemoryChatHistoryProviderTests var requestMessages = new List { new(ChatRole.User, "Hello"), - new(ChatRole.System, "additional context") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.ChatHistory } } }, + new(ChatRole.System, "additional context") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, "TestSource") } } }, }; var responseMessages = new List { diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs index e1d3c612c8..e7276f70b1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosChatHistoryProviderTests.cs @@ -286,7 +286,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable new ChatMessage(ChatRole.User, "First message"), new ChatMessage(ChatRole.Assistant, "Second message"), new ChatMessage(ChatRole.User, "Third message"), - new ChatMessage(ChatRole.System, "System context message") { AdditionalProperties = new() { { AgentRequestMessageSourceType.AdditionalPropertiesKey, AgentRequestMessageSourceType.AIContextProvider } } } + new ChatMessage(ChatRole.System, "System context message") { AdditionalProperties = new() { { AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.AIContextProvider, "TestSource") } } } }; var responseMessages = new[] { From a149aaa926927bacee915dc4317df5663438e3e1 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:34:10 -0800 Subject: [PATCH 08/10] =?UTF-8?q?Python:=20[BREAKING]=20Standardize=20Type?= =?UTF-8?q?Var=20naming=20convention=20(TName=20=E2=86=92=20NameT)=20(#377?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * standardized typevar to use suffix T * addressed copilot comments --- .../ag-ui/agent_framework_ag_ui/_client.py | 18 +- .../ag-ui/agent_framework_ag_ui/_types.py | 6 +- .../agents/ui_generator_agent.py | 4 +- python/packages/ag-ui/tests/ag_ui/conftest.py | 18 +- .../agent_framework_anthropic/_chat_client.py | 18 +- .../_agent_provider.py | 24 +- .../agent_framework_azure_ai/_chat_client.py | 18 +- .../agent_framework_azure_ai/_client.py | 20 +- .../_project_provider.py | 24 +- .../packages/azurefunctions/tests/test_app.py | 36 +-- .../azurefunctions/tests/test_entities.py | 2 +- .../agent_framework_bedrock/_chat_client.py | 16 +- .../claude/agent_framework_claude/_agent.py | 20 +- .../packages/core/agent_framework/_agents.py | 34 +-- .../packages/core/agent_framework/_clients.py | 44 ++-- .../core/agent_framework/_middleware.py | 40 ++-- .../core/agent_framework/_pydantic.py | 4 +- .../core/agent_framework/_serialization.py | 12 +- .../packages/core/agent_framework/_threads.py | 12 +- .../packages/core/agent_framework/_tools.py | 22 +- .../packages/core/agent_framework/_types.py | 220 +++++++++--------- .../_workflows/_model_utils.py | 6 +- .../azure/_assistants_client.py | 6 +- .../agent_framework/azure/_chat_client.py | 22 +- .../azure/_responses_client.py | 14 +- .../core/agent_framework/observability.py | 20 +- .../openai/_assistant_provider.py | 24 +- .../openai/_assistants_client.py | 18 +- .../agent_framework/openai/_chat_client.py | 20 +- .../openai/_responses_client.py | 22 +- python/packages/core/tests/core/conftest.py | 12 +- .../agent_framework_declarative/_models.py | 12 +- python/packages/devui/tests/devui/conftest.py | 4 +- .../tests/test_durable_entities.py | 6 +- .../_foundry_local_client.py | 18 +- .../agent_framework_github_copilot/_agent.py | 20 +- .../agent_framework_ollama/_chat_client.py | 14 +- .../agent_framework_purview/_models.py | 4 +- .../chat_client/custom_chat_client.py | 14 +- ...onfigure_otel_providers_with_parameters.py | 12 +- 40 files changed, 443 insertions(+), 437 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index d04550f9c9..c30e4e5926 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -59,17 +59,17 @@ def _unwrap_server_function_call_contents(contents: MutableSequence[Content | di contents[idx] = content.function_call # type: ignore[assignment, union-attr] -TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]]) +BaseChatClientT = TypeVar("BaseChatClientT", bound=type[BaseChatClient[Any]]) -TAGUIChatOptions = TypeVar( - "TAGUIChatOptions", +AGUIChatOptionsT = TypeVar( + "AGUIChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AGUIChatOptions", covariant=True, ) -def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient: +def _apply_server_function_call_unwrap(chat_client: BaseChatClientT) -> BaseChatClientT: """Class decorator that unwraps server-side function calls after tool handling.""" original_get_response = chat_client.get_response @@ -111,11 +111,11 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha @_apply_server_function_call_unwrap class AGUIChatClient( - ChatMiddlewareLayer[TAGUIChatOptions], - FunctionInvocationLayer[TAGUIChatOptions], - ChatTelemetryLayer[TAGUIChatOptions], - BaseChatClient[TAGUIChatOptions], - Generic[TAGUIChatOptions], + ChatMiddlewareLayer[AGUIChatOptionsT], + FunctionInvocationLayer[AGUIChatOptionsT], + ChatTelemetryLayer[AGUIChatOptionsT], + BaseChatClient[AGUIChatOptionsT], + Generic[AGUIChatOptionsT], ): """Chat client for communicating with AG-UI compliant servers. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_types.py b/python/packages/ag-ui/agent_framework_ag_ui/_types.py index 928a755b31..383bf78b5a 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_types.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_types.py @@ -18,8 +18,8 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover -TAGUIChatOptions = TypeVar("TAGUIChatOptions", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +AGUIChatOptionsT = TypeVar("AGUIChatOptionsT", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type] +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) class PredictStateConfig(TypedDict): @@ -84,7 +84,7 @@ class AGUIRequest(BaseModel): # region AG-UI Chat Options TypedDict -class AGUIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +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. diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py index 33848c379c..3f50fc9c07 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/agents/ui_generator_agent.py @@ -165,10 +165,10 @@ _UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate cont For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table). """ -TOptions = TypeVar("TOptions", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type] +OptionsT = TypeVar("OptionsT", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type] -def ui_generator_agent(chat_client: ChatClientProtocol[TOptions]) -> AgentFrameworkAgent: +def ui_generator_agent(chat_client: ChatClientProtocol[OptionsT]) -> AgentFrameworkAgent: """Create a UI generator agent with custom React component rendering. Args: diff --git a/python/packages/ag-ui/tests/ag_ui/conftest.py b/python/packages/ag-ui/tests/ag_ui/conftest.py index 176f4c031d..4612750f5f 100644 --- a/python/packages/ag-ui/tests/ag_ui/conftest.py +++ b/python/packages/ag-ui/tests/ag_ui/conftest.py @@ -21,7 +21,7 @@ from agent_framework import ( Content, SupportsAgentRun, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework._middleware import ChatMiddlewareLayer from agent_framework._tools import FunctionInvocationLayer from agent_framework._types import ResponseStream @@ -37,11 +37,11 @@ ResponseFn = Callable[..., Awaitable[ChatResponse]] class StreamingChatClientStub( - ChatMiddlewareLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - BaseChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + BaseChatClient[OptionsCoT], + Generic[OptionsCoT], ): """Typed streaming stub that satisfies ChatClientProtocol.""" @@ -68,7 +68,7 @@ class StreamingChatClientStub( messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = ..., + options: OptionsCoT | ChatOptions[None] | None = ..., **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -78,7 +78,7 @@ class StreamingChatClientStub( messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = ..., + options: OptionsCoT | ChatOptions[Any] | None = ..., **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -87,7 +87,7 @@ class StreamingChatClientStub( messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: self.last_thread = kwargs.get("thread") diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 641eb52444..1a000ebd69 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -78,7 +78,7 @@ ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024 BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"] STRUCTURED_OUTPUTS_BETA_FLAG: Final[str] = "structured-outputs-2025-11-13" -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Anthropic Chat Options TypedDict @@ -102,7 +102,7 @@ class ThinkingConfig(TypedDict, total=False): budget_tokens: int -class AnthropicChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class AnthropicChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Anthropic-specific chat options. Extends ChatOptions with options specific to Anthropic's Messages API. @@ -160,8 +160,8 @@ class AnthropicChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], conversation_id: None # type: ignore[misc] -TAnthropicOptions = TypeVar( - "TAnthropicOptions", +AnthropicOptionsT = TypeVar( + "AnthropicOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AnthropicChatOptions", covariant=True, @@ -232,11 +232,11 @@ class AnthropicSettings(AFBaseSettings): class AnthropicClient( - ChatMiddlewareLayer[TAnthropicOptions], - FunctionInvocationLayer[TAnthropicOptions], - ChatTelemetryLayer[TAnthropicOptions], - BaseChatClient[TAnthropicOptions], - Generic[TAnthropicOptions], + ChatMiddlewareLayer[AnthropicOptionsT], + FunctionInvocationLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + BaseChatClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], ): """Anthropic Chat client with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py index afeb85ec86..dcc0e9db29 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py @@ -38,17 +38,17 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed ChatAgent[OptionsCoT] returns # Default matches AzureAIAgentClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIAgentOptions", covariant=True, ) -class AzureAIAgentsProvider(Generic[TOptions_co]): +class AzureAIAgentsProvider(Generic[OptionsCoT]): """Provider for Azure AI Agent Service V1 (Persistent Agents API). This provider enables creating, retrieving, and wrapping Azure AI agents as ChatAgent @@ -176,10 +176,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a new agent on the Azure AI service and return a ChatAgent. This method creates a persistent agent on the Azure AI service with the specified @@ -273,10 +273,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Retrieve an existing agent from the service and return a ChatAgent. This method fetches an agent by ID from the Azure AI service @@ -329,10 +329,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Wrap an existing Agent SDK object as a ChatAgent without making HTTP calls. Use this method when you already have an Agent object from a previous @@ -382,10 +382,10 @@ class AzureAIAgentsProvider(Generic[TOptions_co]): self, agent: Agent, provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a ChatAgent from an Agent SDK object. Args: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index dc013e30d7..504f615a0a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -193,8 +193,8 @@ AZURE_AI_AGENT_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Azure AI Agents API parameter names.""" -TAzureAIAgentOptions = TypeVar( - "TAzureAIAgentOptions", +AzureAIAgentOptionsT = TypeVar( + "AzureAIAgentOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIAgentOptions", covariant=True, @@ -205,11 +205,11 @@ TAzureAIAgentOptions = TypeVar( class AzureAIAgentClient( - ChatMiddlewareLayer[TAzureAIAgentOptions], - FunctionInvocationLayer[TAzureAIAgentOptions], - ChatTelemetryLayer[TAzureAIAgentOptions], - BaseChatClient[TAzureAIAgentOptions], - Generic[TAzureAIAgentOptions], + ChatMiddlewareLayer[AzureAIAgentOptionsT], + FunctionInvocationLayer[AzureAIAgentOptionsT], + ChatTelemetryLayer[AzureAIAgentOptionsT], + BaseChatClient[AzureAIAgentOptionsT], + Generic[AzureAIAgentOptionsT], ): """Azure AI Agent Chat client with middleware, telemetry, and function invocation support.""" @@ -1296,12 +1296,12 @@ class AzureAIAgentClient( | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TAzureAIAgentOptions | Mapping[str, Any] | None = None, + default_options: AzureAIAgentOptionsT | Mapping[str, Any] | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_provider: ContextProvider | None = None, middleware: Sequence[MiddlewareTypes] | None = None, **kwargs: Any, - ) -> ChatAgent[TAzureAIAgentOptions]: + ) -> ChatAgent[AzureAIAgentOptionsT]: """Convert this chat client to a ChatAgent. This method creates a ChatAgent instance with this client pre-configured. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 2dd3e8cc8b..8d262b9b13 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -60,15 +60,15 @@ class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): """Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning).""" -TAzureAIClientOptions = TypeVar( - "TAzureAIClientOptions", +AzureAIClientOptionsT = TypeVar( + "AzureAIClientOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIProjectAgentOptions", covariant=True, ) -class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[TAzureAIClientOptions]): +class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): """Raw Azure AI client without middleware, telemetry, or function invocation layers. Warning: @@ -570,12 +570,12 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TAzureAIClientOptions | Mapping[str, Any] | None = None, + default_options: AzureAIClientOptionsT | Mapping[str, Any] | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_provider: ContextProvider | None = None, middleware: Sequence[MiddlewareTypes] | None = None, **kwargs: Any, - ) -> ChatAgent[TAzureAIClientOptions]: + ) -> ChatAgent[AzureAIClientOptionsT]: """Convert this chat client to a ChatAgent. This method creates a ChatAgent instance with this client pre-configured. @@ -615,11 +615,11 @@ class RawAzureAIClient(RawOpenAIResponsesClient[TAzureAIClientOptions], Generic[ class AzureAIClient( - ChatMiddlewareLayer[TAzureAIClientOptions], - FunctionInvocationLayer[TAzureAIClientOptions], - ChatTelemetryLayer[TAzureAIClientOptions], - RawAzureAIClient[TAzureAIClientOptions], - Generic[TAzureAIClientOptions], + ChatMiddlewareLayer[AzureAIClientOptionsT], + FunctionInvocationLayer[AzureAIClientOptionsT], + ChatTelemetryLayer[AzureAIClientOptionsT], + RawAzureAIClient[AzureAIClientOptionsT], + Generic[AzureAIClientOptionsT], ): """Azure AI client with middleware, telemetry, and function invocation support. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index 9c20e08b6c..e486a14560 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -47,17 +47,17 @@ else: logger = get_logger("agent_framework.azure") -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed ChatAgent[OptionsT] returns # Default matches AzureAIClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="AzureAIProjectAgentOptions", covariant=True, ) -class AzureAIProjectAgentProvider(Generic[TOptions_co]): +class AzureAIProjectAgentProvider(Generic[OptionsCoT]): """Provider for Azure AI Agent Service (Responses API). This provider allows you to create, retrieve, and manage Azure AI agents @@ -167,10 +167,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a new agent on the Azure AI service and return a local ChatAgent wrapper. Args: @@ -269,10 +269,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Retrieve an existing agent from the Azure AI service and return a local ChatAgent wrapper. You must provide either name or reference. Use `as_agent()` if you already have @@ -329,10 +329,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Wrap an SDK agent version object into a ChatAgent without making HTTP calls. Use this when you already have an AgentVersionDetails from a previous API call. @@ -369,10 +369,10 @@ class AzureAIProjectAgentProvider(Generic[TOptions_co]): self, details: AgentVersionDetails, provided_tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a ChatAgent from an AgentVersionDetails. Args: diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index f8b414fc34..5a454e6217 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -27,10 +27,10 @@ from agent_framework_durabletask import ( from agent_framework_azurefunctions import AgentFunctionApp from agent_framework_azurefunctions._entities import create_agent_entity -TFunc = TypeVar("TFunc", bound=Callable[..., Any]) +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) -def _identity_decorator(func: TFunc) -> TFunc: +def _identity_decorator(func: FuncT) -> FuncT: return func @@ -165,8 +165,8 @@ class TestAgentFunctionAppSetup: mock_agent = Mock() mock_agent.name = "TestAgent" - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -190,15 +190,15 @@ class TestAgentFunctionAppSetup: def capture_function_name( self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any - ) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + ) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: captured_names.append(name) return func return decorator - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -220,16 +220,16 @@ class TestAgentFunctionAppSetup: captured_routes: list[str | None] = [] - def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: route_key = kwargs.get("route") if kwargs else None captured_routes.append(route_key) return func return decorator - def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator @@ -738,14 +738,14 @@ class TestHttpRunRoute: def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]: captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {} - def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: return func return decorator - def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: route_key = kwargs.get("route") if kwargs else None captured_handlers[route_key] = func return func @@ -1144,8 +1144,8 @@ class TestMCPToolEndpoint: # Capture the health check handler function captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None - def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: - def decorator(func: TFunc) -> TFunc: + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]: + def decorator(func: FuncT) -> FuncT: nonlocal captured_handler captured_handler = func return func diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index 2294101164..eb740daddb 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -14,7 +14,7 @@ from agent_framework import AgentResponse, ChatMessage from agent_framework_azurefunctions._entities import create_agent_entity -TFunc = TypeVar("TFunc", bound=Callable[..., Any]) +FuncT = TypeVar("FuncT", bound=Callable[..., Any]) def _agent_response(text: str | None) -> AgentResponse: diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index ca851269dc..1a6bf01d59 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -62,7 +62,7 @@ __all__ = [ "BedrockSettings", ] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Bedrock Chat Options TypedDict @@ -91,7 +91,7 @@ class BedrockGuardrailConfig(TypedDict, total=False): """How to process guardrails during streaming (sync blocks, async does not).""" -class BedrockChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class BedrockChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Amazon Bedrock Converse API-specific chat options dict. Extends base ChatOptions with Bedrock-specific parameters. @@ -183,7 +183,7 @@ BEDROCK_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Bedrock Converse API parameter names.""" -TBedrockChatOptions = TypeVar("TBedrockChatOptions", bound=TypedDict, default="BedrockChatOptions", covariant=True) # type: ignore[valid-type] +BedrockChatOptionsT = TypeVar("BedrockChatOptionsT", bound=TypedDict, default="BedrockChatOptions", covariant=True) # type: ignore[valid-type] # endregion @@ -219,11 +219,11 @@ class BedrockSettings(AFBaseSettings): class BedrockChatClient( - ChatMiddlewareLayer[TBedrockChatOptions], - FunctionInvocationLayer[TBedrockChatOptions], - ChatTelemetryLayer[TBedrockChatOptions], - BaseChatClient[TBedrockChatOptions], - Generic[TBedrockChatOptions], + ChatMiddlewareLayer[BedrockChatOptionsT], + FunctionInvocationLayer[BedrockChatOptionsT], + ChatTelemetryLayer[BedrockChatOptionsT], + BaseChatClient[BedrockChatOptionsT], + Generic[BedrockChatOptionsT], ): """Async chat client for Amazon Bedrock's Converse API with middleware, telemetry, and function invocation.""" diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 579c2187ef..f5a343d6b9 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -139,15 +139,15 @@ class ClaudeAgentOptions(TypedDict, total=False): """Beta features to enable.""" -TOptions = TypeVar( - "TOptions", +OptionsT = TypeVar( + "OptionsT", bound=TypedDict, # type: ignore[valid-type] default="ClaudeAgentOptions", covariant=True, ) -class ClaudeAgent(BaseAgent, Generic[TOptions]): +class ClaudeAgent(BaseAgent, Generic[OptionsT]): """Claude Agent using Claude Code CLI. Wraps the Claude Agent SDK to provide agentic capabilities including @@ -223,7 +223,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): | str | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] | str] | None = None, - default_options: TOptions | MutableMapping[str, Any] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -330,7 +330,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): normalized = normalize_tools(tool) self._custom_tools.extend(normalized) - async def __aenter__(self) -> ClaudeAgent[TOptions]: + async def __aenter__(self) -> ClaudeAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -561,7 +561,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): *, stream: Literal[True], thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: ... @@ -572,7 +572,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): *, stream: Literal[False] = ..., thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AgentResponse[Any]: ... @@ -582,7 +582,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): *, stream: bool = False, thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]: """Run the agent with the given messages. @@ -611,7 +611,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AgentResponse[Any]: """Internal non-streaming implementation.""" @@ -625,7 +625,7 @@ class ClaudeAgent(BaseAgent, Generic[TOptions]): messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - options: TOptions | MutableMapping[str, Any] | None = None, + options: OptionsT | MutableMapping[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal streaming implementation.""" diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 8d87e65000..58cec8f0b0 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -70,15 +70,15 @@ if TYPE_CHECKING: from ._types import ChatOptions -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None, covariant=True) -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True) +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) logger = get_logger("agent_framework") -TThreadType = TypeVar("TThreadType", bound="AgentThread") -TOptions_co = TypeVar( - "TOptions_co", +ThreadTypeT = TypeVar("ThreadTypeT", bound="AgentThread") +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, @@ -530,7 +530,7 @@ BareAgent = BaseAgent # region ChatAgent -class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] +class RawChatAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] """A Chat Client Agent without middleware or telemetry layers. This is the core chat agent implementation. For most use cases, @@ -613,7 +613,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] def __init__( self, - chat_client: ChatClientProtocol[TOptions_co], + chat_client: ChatClientProtocol[OptionsCoT], instructions: str | None = None, *, id: str | None = None, @@ -624,7 +624,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_provider: ContextProvider | None = None, **kwargs: Any, @@ -789,9 +789,9 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] | MutableMapping[str, Any] | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[AgentResponse[TResponseModelT]]: ... + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload def run( @@ -805,7 +805,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] | MutableMapping[str, Any] | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -821,7 +821,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] | MutableMapping[str, Any] | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -836,7 +836,7 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] | MutableMapping[str, Any] | list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages and options. @@ -1375,8 +1375,8 @@ class RawChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc] class ChatAgent( AgentTelemetryLayer, AgentMiddlewareLayer, - RawChatAgent[TOptions_co], - Generic[TOptions_co], + RawChatAgent[OptionsCoT], + Generic[OptionsCoT], ): """A Chat Client Agent with middleware, telemetry, and full layer support. @@ -1389,7 +1389,7 @@ class ChatAgent( def __init__( self, - chat_client: ChatClientProtocol[TOptions_co], + chat_client: ChatClientProtocol[OptionsCoT], instructions: str | None = None, *, id: str | None = None, @@ -1400,7 +1400,7 @@ class ChatAgent( | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_provider: ContextProvider | None = None, middleware: Sequence[MiddlewareTypes] | None = None, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index d44c8e7f80..1893f27f80 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -58,10 +58,10 @@ if TYPE_CHECKING: from ._types import ChatOptions -TInput = TypeVar("TInput", contravariant=True) +InputT = TypeVar("InputT", contravariant=True) -TEmbedding = TypeVar("TEmbedding") -TBaseChatClient = TypeVar("TBaseChatClient", bound="BaseChatClient") +EmbeddingT = TypeVar("EmbeddingT") +BaseChatClientT = TypeVar("BaseChatClientT", bound="BaseChatClient") logger = get_logger() @@ -74,19 +74,19 @@ __all__ = [ # region ChatClientProtocol Protocol # Contravariant for the Protocol -TOptions_contra = TypeVar( - "TOptions_contra", +OptionsContraT = TypeVar( + "OptionsContraT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", contravariant=True, ) # Used for the overloads that capture the response model type from options -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @runtime_checkable -class ChatClientProtocol(Protocol[TOptions_contra]): +class ChatClientProtocol(Protocol[OptionsContraT]): """A protocol for a chat client that can generate responses. This protocol defines the interface that all chat clients must implement, @@ -139,9 +139,9 @@ class ChatClientProtocol(Protocol[TOptions_contra]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( @@ -149,7 +149,7 @@ class ChatClientProtocol(Protocol[TOptions_contra]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_contra | ChatOptions[None] | None = None, + options: OptionsContraT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -159,7 +159,7 @@ class ChatClientProtocol(Protocol[TOptions_contra]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_contra | ChatOptions[Any] | None = None, + options: OptionsContraT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -168,7 +168,7 @@ class ChatClientProtocol(Protocol[TOptions_contra]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_contra | ChatOptions[Any] | None = None, + options: OptionsContraT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Send input and return the response. @@ -195,15 +195,15 @@ class ChatClientProtocol(Protocol[TOptions_contra]): # region ChatClientBase # Covariant for the BaseChatClient -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): +class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): """Abstract base class for chat clients without middleware wrapping. This abstract base class provides core functionality for chat client implementations, @@ -368,9 +368,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( @@ -378,7 +378,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -388,7 +388,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -397,7 +397,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from a chat client. @@ -442,13 +442,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions_co | Mapping[str, Any] | None = None, + default_options: OptionsCoT | Mapping[str, Any] | None = None, chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None, context_provider: ContextProvider | None = None, middleware: Sequence[MiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a ChatAgent with this client. This is a convenience method that creates a ChatAgent instance with this diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 48f762cdaa..8d63aa2eba 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: from ._tools import FunctionTool from ._types import ChatOptions, ChatResponse, ChatResponseUpdate - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) __all__ = [ "AgentContext", @@ -65,21 +65,21 @@ __all__ = [ ] AgentT = TypeVar("AgentT", bound="SupportsAgentRun") -TContext = TypeVar("TContext") -TUpdate = TypeVar("TUpdate") +ContextT = TypeVar("ContextT") +UpdateT = TypeVar("UpdateT") -class _EmptyAsyncIterator(Generic[TUpdate]): +class _EmptyAsyncIterator(Generic[UpdateT]): """Empty async iterator that yields nothing. Used when middleware terminates without setting a result, and we need to provide an empty stream. """ - def __aiter__(self) -> _EmptyAsyncIterator[TUpdate]: + def __aiter__(self) -> _EmptyAsyncIterator[UpdateT]: return self - async def __anext__(self) -> TUpdate: + async def __anext__(self) -> UpdateT: raise StopAsyncIteration @@ -656,20 +656,20 @@ def chat_middleware(func: ChatMiddlewareCallable) -> ChatMiddlewareCallable: return func -class MiddlewareWrapper(Generic[TContext]): +class MiddlewareWrapper(Generic[ContextT]): """Generic wrapper to convert pure functions into middleware protocol objects. This wrapper allows function-based middleware to be used alongside class-based middleware by providing a unified interface. Type Parameters: - TContext: The type of context object this middleware operates on. + ContextT: The type of context object this middleware operates on. """ - def __init__(self, func: Callable[[TContext, Callable[[TContext], Awaitable[None]]], Awaitable[None]]) -> None: + def __init__(self, func: Callable[[ContextT, Callable[[ContextT], Awaitable[None]]], Awaitable[None]]) -> None: self.func = func - async def process(self, context: TContext, call_next: Callable[[TContext], Awaitable[None]]) -> None: + async def process(self, context: ContextT, call_next: Callable[[ContextT], Awaitable[None]]) -> None: await self.func(context, call_next) @@ -953,15 +953,15 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline): # Covariant for chat client options -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class ChatMiddlewareLayer(Generic[TOptions_co]): +class ChatMiddlewareLayer(Generic[OptionsCoT]): """Layer for chat clients to apply chat middleware around response generation.""" def __init__( @@ -983,9 +983,9 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( @@ -993,7 +993,7 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -1003,7 +1003,7 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -1012,7 +1012,7 @@ class ChatMiddlewareLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Execute the chat pipeline if middleware is configured.""" @@ -1102,9 +1102,9 @@ class AgentMiddlewareLayer: stream: Literal[False] = ..., thread: AgentThread | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[AgentResponse[TResponseModelT]]: ... + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload def run( diff --git a/python/packages/core/agent_framework/_pydantic.py b/python/packages/core/agent_framework/_pydantic.py index a54f7b81af..b8652745b3 100644 --- a/python/packages/core/agent_framework/_pydantic.py +++ b/python/packages/core/agent_framework/_pydantic.py @@ -14,7 +14,7 @@ HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["h __all__ = ["AFBaseSettings", "HTTPsUrl"] -TSettings = TypeVar("TSettings", bound="AFBaseSettings") +SettingsT = TypeVar("SettingsT", bound="AFBaseSettings") class AFBaseSettings(BaseSettings): @@ -50,7 +50,7 @@ class AFBaseSettings(BaseSettings): kwargs = {k: v for k, v in kwargs.items() if v is not None} super().__init__(**kwargs) - def __new__(cls: type[TSettings], *args: Any, **kwargs: Any) -> TSettings: + def __new__(cls: type[SettingsT], *args: Any, **kwargs: Any) -> SettingsT: """Override the __new__ method to set the env_prefix.""" # for both, if supplied but None, set to default if "env_file_encoding" in kwargs and kwargs["env_file_encoding"] is not None: diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index c70f73e1d2..44de6ef848 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -11,8 +11,8 @@ from ._logging import get_logger logger = get_logger() -TClass = TypeVar("TClass", bound="SerializationMixin") -TProtocol = TypeVar("TProtocol", bound="SerializationProtocol") +ClassT = TypeVar("ClassT", bound="SerializationMixin") +ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol") # Regex pattern for converting CamelCase to snake_case _CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? TProtocol: + def from_dict(cls: type[ProtocolT], value: MutableMapping[str, Any], /, **kwargs: Any) -> ProtocolT: """Create an instance from a dictionary. Args: @@ -392,8 +392,8 @@ class SerializationMixin: @classmethod def from_dict( - cls: type[TClass], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None - ) -> TClass: + cls: type[ClassT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> ClassT: """Create an instance from a dictionary with optional dependency injection. This method reconstructs an object from its dictionary representation, automatically @@ -560,7 +560,7 @@ class SerializationMixin: return cls(**kwargs) @classmethod - def from_json(cls: type[TClass], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> TClass: + def from_json(cls: type[ClassT], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> ClassT: """Create an instance from a JSON string. This is a convenience method that parses the JSON string using ``json.loads()`` diff --git a/python/packages/core/agent_framework/_threads.py b/python/packages/core/agent_framework/_threads.py index 74462d3a90..df142643f6 100644 --- a/python/packages/core/agent_framework/_threads.py +++ b/python/packages/core/agent_framework/_threads.py @@ -182,7 +182,7 @@ class AgentThreadState(SerializationMixin): raise TypeError("Could not parse ChatMessageStoreState.") -TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore") +ChatMessageStoreT = TypeVar("ChatMessageStoreT", bound="ChatMessageStore") class ChatMessageStore: @@ -243,8 +243,8 @@ class ChatMessageStore: @classmethod async def deserialize( - cls: type[TChatMessageStore], serialized_store_state: MutableMapping[str, Any], **kwargs: Any - ) -> TChatMessageStore: + cls: type[ChatMessageStoreT], serialized_store_state: MutableMapping[str, Any], **kwargs: Any + ) -> ChatMessageStoreT: """Create a new ChatMessageStore instance from serialized state data. Args: @@ -289,7 +289,7 @@ class ChatMessageStore: return state.to_dict() -TAgentThread = TypeVar("TAgentThread", bound="AgentThread") +AgentThreadT = TypeVar("AgentThreadT", bound="AgentThread") class AgentThread: @@ -437,12 +437,12 @@ class AgentThread: @classmethod async def deserialize( - cls: type[TAgentThread], + cls: type[AgentThreadT], serialized_thread_state: MutableMapping[str, Any], *, message_store: ChatMessageStoreProtocol | None = None, **kwargs: Any, - ) -> TAgentThread: + ) -> AgentThreadT: """Deserializes the state from a dictionary into a new AgentThread instance. Args: diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 5dec72d02c..da490d772e 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -76,7 +76,7 @@ if TYPE_CHECKING: ResponseStream, ) - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) logger = get_logger() @@ -100,7 +100,7 @@ __all__ = [ logger = get_logger() DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 -TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]") +ChatClientT = TypeVar("ChatClientT", bound="ChatClientProtocol[Any]") # region Helpers ArgsT = TypeVar("ArgsT", bound=BaseModel, default=BaseModel) @@ -569,7 +569,7 @@ def _default_histogram() -> Histogram: ) -TClass = TypeVar("TClass", bound="SerializationMixin") +ClassT = TypeVar("ClassT", bound="SerializationMixin") class EmptyInputModel(BaseModel): @@ -2083,15 +2083,15 @@ async def _process_function_requests( return result -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class FunctionInvocationLayer(Generic[TOptions_co]): +class FunctionInvocationLayer(Generic[OptionsCoT]): """Layer for chat clients to apply function invocation around get_response.""" def __init__( @@ -2115,9 +2115,9 @@ class FunctionInvocationLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( @@ -2125,7 +2125,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -2135,7 +2135,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -2144,7 +2144,7 @@ class FunctionInvocationLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b5fc029894..9c28a5a7e1 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -36,17 +36,17 @@ __all__ = [ "ChatResponse", "ChatResponseUpdate", "Content", + "FinalT", "FinishReason", "FinishReasonLiteral", + "OuterFinalT", + "OuterUpdateT", "ResponseStream", "Role", "RoleLiteral", - "TFinal", - "TOuterFinal", - "TOuterUpdate", - "TUpdate", "TextSpanRegion", "ToolMode", + "UpdateT", "UsageDetails", "add_usage_details", "detect_media_type_from_base64", @@ -305,12 +305,12 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any: # region Constants and types _T = TypeVar("_T") -TEmbedding = TypeVar("TEmbedding") -TChatResponse = TypeVar("TChatResponse", bound="ChatResponse") -TToolMode = TypeVar("TToolMode", bound="ToolMode") -TAgentRunResponse = TypeVar("TAgentRunResponse", bound="AgentResponse") -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None, covariant=True) -TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) +EmbeddingT = TypeVar("EmbeddingT") +ChatResponseT = TypeVar("ChatResponseT", bound="ChatResponse") +ToolModeT = TypeVar("ToolModeT", bound="ToolMode") +AgentResponseT = TypeVar("AgentResponseT", bound="AgentResponse") +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True) +ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) CreatedAtT = str # Use a datetimeoffset type? Or a more specific type like datetime.datetime? @@ -389,7 +389,7 @@ class Annotation(TypedDict, total=False): raw_representation: Any -TContent = TypeVar("TContent", bound="Content") +ContentT = TypeVar("ContentT", bound="Content") # endregion @@ -544,13 +544,13 @@ class Content: @classmethod def from_text( - cls: type[TContent], + cls: type[ContentT], text: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create text content.""" return cls( "text", @@ -562,14 +562,14 @@ class Content: @classmethod def from_text_reasoning( - cls: type[TContent], + cls: type[ContentT], *, text: str | None = None, protected_data: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create text reasoning content.""" return cls( "text_reasoning", @@ -582,14 +582,14 @@ class Content: @classmethod def from_data( - cls: type[TContent], + cls: type[ContentT], data: bytes, media_type: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: r"""Create data content from raw binary data. Use this to create content from binary data (images, audio, documents, etc.). @@ -658,14 +658,14 @@ class Content: @classmethod def from_uri( - cls: type[TContent], + cls: type[ContentT], uri: str, *, media_type: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create content from a URI, can be both data URI or external URI. Use this when you already have a properly formed data URI @@ -720,7 +720,7 @@ class Content: @classmethod def from_error( - cls: type[TContent], + cls: type[ContentT], *, message: str | None = None, error_code: str | None = None, @@ -728,7 +728,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create error content.""" return cls( "error", @@ -742,7 +742,7 @@ class Content: @classmethod def from_function_call( - cls: type[TContent], + cls: type[ContentT], call_id: str, name: str, *, @@ -751,7 +751,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function call content.""" return cls( "function_call", @@ -766,7 +766,7 @@ class Content: @classmethod def from_function_result( - cls: type[TContent], + cls: type[ContentT], call_id: str, *, result: Any = None, @@ -774,7 +774,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function result content.""" return cls( "function_result", @@ -788,13 +788,13 @@ class Content: @classmethod def from_usage( - cls: type[TContent], + cls: type[ContentT], usage_details: UsageDetails, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create usage content.""" return cls( "usage", @@ -806,7 +806,7 @@ class Content: @classmethod def from_hosted_file( - cls: type[TContent], + cls: type[ContentT], file_id: str, *, media_type: str | None = None, @@ -814,7 +814,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create hosted file content.""" return cls( "hosted_file", @@ -828,13 +828,13 @@ class Content: @classmethod def from_hosted_vector_store( - cls: type[TContent], + cls: type[ContentT], vector_store_id: str, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create hosted vector store content.""" return cls( "hosted_vector_store", @@ -846,14 +846,14 @@ class Content: @classmethod def from_code_interpreter_tool_call( - cls: type[TContent], + cls: type[ContentT], *, call_id: str | None = None, inputs: Sequence[Content] | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create code interpreter tool call content.""" return cls( "code_interpreter_tool_call", @@ -866,14 +866,14 @@ class Content: @classmethod def from_code_interpreter_tool_result( - cls: type[TContent], + cls: type[ContentT], *, call_id: str | None = None, outputs: Sequence[Content] | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create code interpreter tool result content.""" return cls( "code_interpreter_tool_result", @@ -886,13 +886,13 @@ class Content: @classmethod def from_image_generation_tool_call( - cls: type[TContent], + cls: type[ContentT], *, image_id: str | None = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create image generation tool call content.""" return cls( "image_generation_tool_call", @@ -904,14 +904,14 @@ class Content: @classmethod def from_image_generation_tool_result( - cls: type[TContent], + cls: type[ContentT], *, image_id: str | None = None, outputs: Any = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create image generation tool result content.""" return cls( "image_generation_tool_result", @@ -924,7 +924,7 @@ class Content: @classmethod def from_mcp_server_tool_call( - cls: type[TContent], + cls: type[ContentT], call_id: str, tool_name: str, *, @@ -933,7 +933,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create MCP server tool call content.""" return cls( "mcp_server_tool_call", @@ -948,14 +948,14 @@ class Content: @classmethod def from_mcp_server_tool_result( - cls: type[TContent], + cls: type[ContentT], call_id: str, *, output: Any = None, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create MCP server tool result content.""" return cls( "mcp_server_tool_result", @@ -968,14 +968,14 @@ class Content: @classmethod def from_function_approval_request( - cls: type[TContent], + cls: type[ContentT], id: str, function_call: Content, *, annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function approval request content.""" return cls( "function_approval_request", @@ -989,7 +989,7 @@ class Content: @classmethod def from_function_approval_response( - cls: type[TContent], + cls: type[ContentT], approved: bool, id: str, function_call: Content, @@ -997,7 +997,7 @@ class Content: annotations: Sequence[Annotation] | None = None, additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, - ) -> TContent: + ) -> ContentT: """Create function approval response content.""" return cls( "function_approval_response", @@ -1091,7 +1091,7 @@ class Content: return f"Content(type={self.type})" @classmethod - def from_dict(cls: type[TContent], data: Mapping[str, Any]) -> TContent: + def from_dict(cls: type[ContentT], data: Mapping[str, Any]) -> ContentT: """Create a Content instance from a mapping.""" if not (content_type := data.get("type")): raise ValueError("Content mapping requires 'type'") @@ -1796,7 +1796,7 @@ def _finalize_response(response: ChatResponse | AgentResponse) -> None: _coalesce_text_content(msg.contents, "text_reasoning") -class ChatResponse(SerializationMixin, Generic[TResponseModel]): +class ChatResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to a chat request. Attributes: @@ -1859,7 +1859,7 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): created_at: CreatedAtT | None = None, finish_reason: FinishReasonLiteral | FinishReason | None = None, usage_details: UsageDetails | None = None, - value: TResponseModel | None = None, + value: ResponseModelT | None = None, response_format: type[BaseModel] | None = None, additional_properties: dict[str, Any] | None = None, raw_representation: Any | None = None, @@ -1903,7 +1903,7 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): finish_reason = finish_reason["value"] self.finish_reason = finish_reason self.usage_details = usage_details - self._value: TResponseModel | None = value + self._value: ResponseModelT | None = value self._response_format: type[BaseModel] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = additional_properties or {} @@ -1915,8 +1915,8 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): cls: type[ChatResponse[Any]], updates: Sequence[ChatResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> ChatResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> ChatResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -1929,11 +1929,11 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): @classmethod def from_updates( - cls: type[TChatResponse], + cls: type[ChatResponseT], updates: Sequence[ChatResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TChatResponse: + ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. Example: @@ -1970,8 +1970,8 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): cls: type[ChatResponse[Any]], updates: AsyncIterable[ChatResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> ChatResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> ChatResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -1984,11 +1984,11 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): @classmethod async def from_update_generator( - cls: type[TChatResponse], + cls: type[ChatResponseT], updates: AsyncIterable[ChatResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TChatResponse: + ) -> ChatResponseT: """Joins multiple updates into a single ChatResponse. Example: @@ -2021,7 +2021,7 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): return ("\n".join(message.text for message in self.messages if isinstance(message, ChatMessage))).strip() @property - def value(self) -> TResponseModel | None: + def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, @@ -2037,7 +2037,7 @@ class ChatResponse(SerializationMixin, Generic[TResponseModel]): and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(TResponseModel, self._response_format.model_validate_json(self.text)) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) self._value_parsed = True return self._value @@ -2166,7 +2166,7 @@ class ChatResponseUpdate(SerializationMixin): # region AgentResponse -class AgentResponse(SerializationMixin, Generic[TResponseModel]): +class AgentResponse(SerializationMixin, Generic[ResponseModelT]): """Represents the response to an Agent run request. Provides one or more response messages and metadata about the response. @@ -2220,7 +2220,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): agent_id: str | None = None, created_at: CreatedAtT | None = None, usage_details: UsageDetails | None = None, - value: TResponseModel | None = None, + value: ResponseModelT | None = None, response_format: type[BaseModel] | None = None, raw_representation: Any | None = None, additional_properties: dict[str, Any] | None = None, @@ -2258,7 +2258,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): self.agent_id = agent_id self.created_at = created_at self.usage_details = usage_details - self._value: TResponseModel | None = value + self._value: ResponseModelT | None = value self._response_format: type[BaseModel] | None = response_format self._value_parsed: bool = value is not None self.additional_properties = additional_properties or {} @@ -2270,7 +2270,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): return "".join(msg.text for msg in self.messages) if self.messages else "" @property - def value(self) -> TResponseModel | None: + def value(self) -> ResponseModelT | None: """Get the parsed structured output value. If a response_format was provided and parsing hasn't been attempted yet, @@ -2286,7 +2286,7 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): and isinstance(self._response_format, type) and issubclass(self._response_format, BaseModel) ): - self._value = cast(TResponseModel, self._response_format.model_validate_json(self.text)) + self._value = cast(ResponseModelT, self._response_format.model_validate_json(self.text)) self._value_parsed = True return self._value @@ -2306,8 +2306,8 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): cls: type[AgentResponse[Any]], updates: Sequence[AgentResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> AgentResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> AgentResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -2320,11 +2320,11 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): @classmethod def from_updates( - cls: type[TAgentRunResponse], + cls: type[AgentResponseT], updates: Sequence[AgentResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TAgentRunResponse: + ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. Args: @@ -2345,8 +2345,8 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): cls: type[AgentResponse[Any]], updates: AsyncIterable[AgentResponseUpdate], *, - output_format_type: type[TResponseModelT], - ) -> AgentResponse[TResponseModelT]: ... + output_format_type: type[ResponseModelBoundT], + ) -> AgentResponse[ResponseModelBoundT]: ... @overload @classmethod @@ -2359,11 +2359,11 @@ class AgentResponse(SerializationMixin, Generic[TResponseModel]): @classmethod async def from_update_generator( - cls: type[TAgentRunResponse], + cls: type[AgentResponseT], updates: AsyncIterable[AgentResponseUpdate], *, output_format_type: type[BaseModel] | None = None, - ) -> TAgentRunResponse: + ) -> AgentResponseT: """Joins multiple updates into a single AgentResponse. Args: @@ -2520,23 +2520,23 @@ def map_chat_to_agent_update(update: ChatResponseUpdate, agent_name: str | None) # Type variables for ResponseStream -TUpdate = TypeVar("TUpdate") -TFinal = TypeVar("TFinal") -TOuterUpdate = TypeVar("TOuterUpdate") -TOuterFinal = TypeVar("TOuterFinal") +UpdateT = TypeVar("UpdateT") +FinalT = TypeVar("FinalT") +OuterUpdateT = TypeVar("OuterUpdateT") +OuterFinalT = TypeVar("OuterFinalT") -class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): +class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]): """Async stream wrapper that supports iteration and deferred finalization.""" def __init__( self, - stream: AsyncIterable[TUpdate] | Awaitable[AsyncIterable[TUpdate]], + stream: AsyncIterable[UpdateT] | Awaitable[AsyncIterable[UpdateT]], *, - finalizer: Callable[[Sequence[TUpdate]], TFinal | Awaitable[TFinal]] | None = None, - transform_hooks: list[Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None]] | None = None, + finalizer: Callable[[Sequence[UpdateT]], FinalT | Awaitable[FinalT]] | None = None, + transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] | None = None, cleanup_hooks: list[Callable[[], Awaitable[None] | None]] | None = None, - result_hooks: list[Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None]] | None = None, + result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] | None = None, ) -> None: """A Async Iterable stream of updates. @@ -2552,16 +2552,16 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): """ self._stream_source = stream self._finalizer = finalizer - self._stream: AsyncIterable[TUpdate] | None = None - self._iterator: AsyncIterator[TUpdate] | None = None - self._updates: list[TUpdate] = [] + self._stream: AsyncIterable[UpdateT] | None = None + self._iterator: AsyncIterator[UpdateT] | None = None + self._updates: list[UpdateT] = [] self._consumed: bool = False self._finalized: bool = False - self._final_result: TFinal | None = None - self._transform_hooks: list[Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None]] = ( + self._final_result: FinalT | None = None + self._transform_hooks: list[Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None]] = ( transform_hooks if transform_hooks is not None else [] ) - self._result_hooks: list[Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None]] = ( + self._result_hooks: list[Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None]] = ( result_hooks if result_hooks is not None else [] ) self._cleanup_hooks: list[Callable[[], Awaitable[None] | None]] = ( @@ -2575,9 +2575,9 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def map( self, - transform: Callable[[TUpdate], TOuterUpdate | Awaitable[TOuterUpdate]], - finalizer: Callable[[Sequence[TOuterUpdate]], TOuterFinal | Awaitable[TOuterFinal]], - ) -> ResponseStream[TOuterUpdate, TOuterFinal]: + transform: Callable[[UpdateT], OuterUpdateT | Awaitable[OuterUpdateT]], + finalizer: Callable[[Sequence[OuterUpdateT]], OuterFinalT | Awaitable[OuterFinalT]], + ) -> ResponseStream[OuterUpdateT, OuterFinalT]: """Create a new stream that transforms each update. The returned stream delegates iteration to this stream, ensuring single consumption. @@ -2619,8 +2619,8 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_finalizer( self, - finalizer: Callable[[Sequence[TUpdate]], TOuterFinal | Awaitable[TOuterFinal]], - ) -> ResponseStream[TUpdate, TOuterFinal]: + finalizer: Callable[[Sequence[UpdateT]], OuterFinalT | Awaitable[OuterFinalT]], + ) -> ResponseStream[UpdateT, OuterFinalT]: """Create a new stream with a different finalizer. The returned stream delegates iteration to this stream, ensuring single consumption. @@ -2647,8 +2647,8 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): @classmethod def from_awaitable( cls, - awaitable: Awaitable[ResponseStream[TUpdate, TFinal]], - ) -> ResponseStream[TUpdate, TFinal]: + awaitable: Awaitable[ResponseStream[UpdateT, FinalT]], + ) -> ResponseStream[UpdateT, FinalT]: """Create a ResponseStream from an awaitable that resolves to a ResponseStream. This is useful when you have an async function that returns a ResponseStream @@ -2672,7 +2672,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): stream._wrap_inner = True return stream # type: ignore[return-value] - async def _get_stream(self) -> AsyncIterable[TUpdate]: + async def _get_stream(self) -> AsyncIterable[UpdateT]: if self._stream is None: if hasattr(self._stream_source, "__aiter__"): self._stream = self._stream_source # type: ignore[assignment] @@ -2686,10 +2686,10 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): return self._stream return self._stream # type: ignore[return-value] - def __aiter__(self) -> ResponseStream[TUpdate, TFinal]: + def __aiter__(self) -> ResponseStream[UpdateT, FinalT]: return self - async def __anext__(self) -> TUpdate: + async def __anext__(self) -> UpdateT: if self._iterator is None: stream = await self._get_stream() self._iterator = stream.__aiter__() @@ -2718,19 +2718,19 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): return update def __await__(self) -> Any: - async def _wrap() -> ResponseStream[TUpdate, TFinal]: + async def _wrap() -> ResponseStream[UpdateT, FinalT]: await self._get_stream() return self return _wrap().__await__() - async def get_final_response(self) -> TFinal: + async def get_final_response(self) -> FinalT: """Get the final response by applying the finalizer to all collected updates. If a finalizer is configured, it receives the list of updates and returns the final type. Result hooks are then applied in order to transform the result. - If no finalizer is configured, returns the collected updates as Sequence[TUpdate]. + If no finalizer is configured, returns the collected updates as Sequence[UpdateT]. For wrapped streams (created via .map() or .from_awaitable()): - The inner stream's finalizer is called first to produce the inner final result. @@ -2815,16 +2815,16 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_transform_hook( self, - hook: Callable[[TUpdate], TUpdate | Awaitable[TUpdate] | None], - ) -> ResponseStream[TUpdate, TFinal]: + hook: Callable[[UpdateT], UpdateT | Awaitable[UpdateT] | None], + ) -> ResponseStream[UpdateT, FinalT]: """Register a transform hook executed for each update during iteration.""" self._transform_hooks.append(hook) return self def with_result_hook( self, - hook: Callable[[TFinal], TFinal | Awaitable[TFinal | None] | None], - ) -> ResponseStream[TUpdate, TFinal]: + hook: Callable[[FinalT], FinalT | Awaitable[FinalT | None] | None], + ) -> ResponseStream[UpdateT, FinalT]: """Register a result hook executed after finalization.""" self._result_hooks.append(hook) self._finalized = False @@ -2834,7 +2834,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): def with_cleanup_hook( self, hook: Callable[[], Awaitable[None] | None], - ) -> ResponseStream[TUpdate, TFinal]: + ) -> ResponseStream[UpdateT, FinalT]: """Register a cleanup hook executed after stream consumption (before finalizer).""" self._cleanup_hooks.append(hook) return self @@ -2849,7 +2849,7 @@ class ResponseStream(AsyncIterable[TUpdate], Generic[TUpdate, TFinal]): await result @property - def updates(self) -> Sequence[TUpdate]: + def updates(self) -> Sequence[UpdateT]: return self._updates @@ -2944,8 +2944,8 @@ class _ChatOptionsBase(TypedDict, total=False): if TYPE_CHECKING: - class ChatOptions(_ChatOptionsBase, Generic[TResponseModel], total=False): - response_format: type[TResponseModel] | Mapping[str, Any] | None # type: ignore[misc] + class ChatOptions(_ChatOptionsBase, Generic[ResponseModelT], total=False): + response_format: type[ResponseModelT] | Mapping[str, Any] | None # type: ignore[misc] else: ChatOptions = _ChatOptionsBase diff --git a/python/packages/core/agent_framework/_workflows/_model_utils.py b/python/packages/core/agent_framework/_workflows/_model_utils.py index 72380901c6..0627d716a8 100644 --- a/python/packages/core/agent_framework/_workflows/_model_utils.py +++ b/python/packages/core/agent_framework/_workflows/_model_utils.py @@ -9,7 +9,7 @@ if sys.version_info >= (3, 11): else: from typing_extensions import Self # pragma: no cover -TModel = TypeVar("TModel", bound="DictConvertible") +ModelT = TypeVar("ModelT", bound="DictConvertible") class DictConvertible: @@ -19,7 +19,7 @@ class DictConvertible: raise NotImplementedError @classmethod - def from_dict(cls: type[TModel], data: dict[str, Any]) -> TModel: + def from_dict(cls: type[ModelT], data: dict[str, Any]) -> ModelT: return cls(**data) # type: ignore[arg-type] def clone(self, *, deep: bool = True) -> Self: @@ -31,7 +31,7 @@ class DictConvertible: return json.dumps(self.to_dict()) @classmethod - def from_json(cls: type[TModel], raw: str) -> TModel: + def from_json(cls: type[ModelT], raw: str) -> ModelT: import json data = json.loads(raw) diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py index 3ded58e9b0..52d219529b 100644 --- a/python/packages/core/agent_framework/azure/_assistants_client.py +++ b/python/packages/core/agent_framework/azure/_assistants_client.py @@ -32,8 +32,8 @@ __all__ = ["AzureOpenAIAssistantsClient"] # region Azure OpenAI Assistants Options TypedDict -TAzureOpenAIAssistantsOptions = TypeVar( - "TAzureOpenAIAssistantsOptions", +AzureOpenAIAssistantsOptionsT = TypeVar( + "AzureOpenAIAssistantsOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, @@ -44,7 +44,7 @@ TAzureOpenAIAssistantsOptions = TypeVar( class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[TAzureOpenAIAssistantsOptions], Generic[TAzureOpenAIAssistantsOptions] + OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] ): """Azure OpenAI Assistants client.""" diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py index a603af52cc..0fcf99823a 100644 --- a/python/packages/core/agent_framework/azure/_chat_client.py +++ b/python/packages/core/agent_framework/azure/_chat_client.py @@ -53,7 +53,7 @@ logger: logging.Logger = logging.getLogger(__name__) __all__ = ["AzureOpenAIChatClient", "AzureOpenAIChatOptions", "AzureUserSecurityContext"] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Azure OpenAI Chat Options TypedDict @@ -81,7 +81,7 @@ class AzureUserSecurityContext(TypedDict, total=False): """The original client's IP address.""" -class AzureOpenAIChatOptions(OpenAIChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class AzureOpenAIChatOptions(OpenAIChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Azure OpenAI-specific chat options dict. Extends OpenAIChatOptions with Azure-specific options including @@ -136,8 +136,8 @@ class AzureOpenAIChatOptions(OpenAIChatOptions[TResponseModel], Generic[TRespons Note: You will be charged based on tokens across all choices. Keep n=1 to minimize costs.""" -TAzureOpenAIChatOptions = TypeVar( - "TAzureOpenAIChatOptions", +AzureOpenAIChatOptionsT = TypeVar( + "AzureOpenAIChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="AzureOpenAIChatOptions", covariant=True, @@ -146,17 +146,17 @@ TAzureOpenAIChatOptions = TypeVar( # endregion -TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate) -TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAIChatClient") +ChatResponseT = TypeVar("ChatResponseT", ChatResponse, ChatResponseUpdate) +AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAIChatClient") class AzureOpenAIChatClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[TAzureOpenAIChatOptions], - FunctionInvocationLayer[TAzureOpenAIChatOptions], - ChatTelemetryLayer[TAzureOpenAIChatOptions], - RawOpenAIChatClient[TAzureOpenAIChatOptions], - Generic[TAzureOpenAIChatOptions], + ChatMiddlewareLayer[AzureOpenAIChatOptionsT], + FunctionInvocationLayer[AzureOpenAIChatOptionsT], + ChatTelemetryLayer[AzureOpenAIChatOptionsT], + RawOpenAIChatClient[AzureOpenAIChatOptionsT], + Generic[AzureOpenAIChatOptionsT], ): """Azure OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py index 11eee1900f..cc57beb57c 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -41,8 +41,8 @@ if TYPE_CHECKING: __all__ = ["AzureOpenAIResponsesClient"] -TAzureOpenAIResponsesOptions = TypeVar( - "TAzureOpenAIResponsesOptions", +AzureOpenAIResponsesOptionsT = TypeVar( + "AzureOpenAIResponsesOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIResponsesOptions", covariant=True, @@ -51,11 +51,11 @@ TAzureOpenAIResponsesOptions = TypeVar( class AzureOpenAIResponsesClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[TAzureOpenAIResponsesOptions], - FunctionInvocationLayer[TAzureOpenAIResponsesOptions], - ChatTelemetryLayer[TAzureOpenAIResponsesOptions], - RawOpenAIResponsesClient[TAzureOpenAIResponsesOptions], - Generic[TAzureOpenAIResponsesOptions], + ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], + FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], + ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], + RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT], + Generic[AzureOpenAIResponsesOptionsT], ): """Azure Responses completion class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9a839bb566..34c58b3b1a 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -54,7 +54,7 @@ if TYPE_CHECKING: # pragma: no cover ResponseStream, ) - TResponseModelT = TypeVar("TResponseModelT", bound=BaseModel) + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) __all__ = [ "OBSERVABILITY_SETTINGS", @@ -71,7 +71,7 @@ __all__ = [ AgentT = TypeVar("AgentT", bound="SupportsAgentRun") -TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]") +ChatClientT = TypeVar("ChatClientT", bound="ChatClientProtocol[Any]") logger = get_logger() @@ -1049,15 +1049,15 @@ def _get_token_usage_histogram() -> metrics.Histogram: ) -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="ChatOptions[None]", covariant=True, ) -class ChatTelemetryLayer(Generic[TOptions_co]): +class ChatTelemetryLayer(Generic[OptionsCoT]): """Layer that wraps chat client get_response with OpenTelemetry tracing.""" def __init__(self, *args: Any, otel_provider_name: str | None = None, **kwargs: Any) -> None: @@ -1073,9 +1073,9 @@ class ChatTelemetryLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: ChatOptions[TResponseModelT], + options: ChatOptions[ResponseModelBoundT], **kwargs: Any, - ) -> Awaitable[ChatResponse[TResponseModelT]]: ... + ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload def get_response( @@ -1083,7 +1083,7 @@ class ChatTelemetryLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[False] = ..., - options: TOptions_co | ChatOptions[None] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @@ -1093,7 +1093,7 @@ class ChatTelemetryLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: Literal[True], - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @@ -1102,7 +1102,7 @@ class ChatTelemetryLayer(Generic[TOptions_co]): messages: str | ChatMessage | Sequence[str | ChatMessage], *, stream: bool = False, - options: TOptions_co | ChatOptions[Any] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Trace chat responses with OpenTelemetry spans and metrics.""" diff --git a/python/packages/core/agent_framework/openai/_assistant_provider.py b/python/packages/core/agent_framework/openai/_assistant_provider.py index 263c4dcab1..7b662e4c2a 100644 --- a/python/packages/core/agent_framework/openai/_assistant_provider.py +++ b/python/packages/core/agent_framework/openai/_assistant_provider.py @@ -33,10 +33,10 @@ else: __all__ = ["OpenAIAssistantProvider"] -# Type variable for options - allows typed ChatAgent[TOptions] returns +# Type variable for options - allows typed OpenAIAssistantProvider[OptionsCoT] returns # Default matches OpenAIAssistantsClient's default options type -TOptions_co = TypeVar( - "TOptions_co", +OptionsCoT = TypeVar( + "OptionsCoT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, @@ -50,7 +50,7 @@ _ToolsType = ( ) -class OpenAIAssistantProvider(Generic[TOptions_co]): +class OpenAIAssistantProvider(Generic[OptionsCoT]): """Provider for creating ChatAgent instances from OpenAI Assistants API. This provider allows you to create, retrieve, and wrap OpenAI Assistants @@ -205,10 +205,10 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): description: str | None = None, tools: _ToolsType | None = None, metadata: dict[str, str] | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a new assistant on OpenAI and return a ChatAgent. This method creates a new assistant on the OpenAI service and wraps it @@ -313,10 +313,10 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): *, tools: _ToolsType | None = None, instructions: str | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Retrieve an existing assistant by ID and return a ChatAgent. This method fetches an existing assistant from OpenAI by its ID @@ -379,10 +379,10 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): *, tools: _ToolsType | None = None, instructions: str | None = None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, middleware: Sequence[MiddlewareTypes] | None = None, context_provider: ContextProvider | None = None, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Wrap an existing SDK Assistant object as a ChatAgent. This method does NOT make any HTTP calls. It simply wraps an already- @@ -524,9 +524,9 @@ class OpenAIAssistantProvider(Generic[TOptions_co]): instructions: str | None, middleware: Sequence[MiddlewareTypes] | None, context_provider: ContextProvider | None, - default_options: TOptions_co | None = None, + default_options: OptionsCoT | None = None, **kwargs: Any, - ) -> ChatAgent[TOptions_co]: + ) -> ChatAgent[OptionsCoT]: """Create a ChatAgent from an Assistant. Args: diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 1f6bdb87dc..914109827b 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -79,7 +79,7 @@ __all__ = [ # region OpenAI Assistants Options TypedDict -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) class VectorStoreToolResource(TypedDict, total=False): @@ -109,7 +109,7 @@ class AssistantToolResources(TypedDict, total=False): """Resources for file search tool, including vector store IDs.""" -class OpenAIAssistantsOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """OpenAI Assistants API-specific options dict. Extends base ChatOptions with Assistants API-specific parameters @@ -193,8 +193,8 @@ ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to OpenAI Assistants API parameter names.""" -TOpenAIAssistantsOptions = TypeVar( - "TOpenAIAssistantsOptions", +OpenAIAssistantsOptionsT = TypeVar( + "OpenAIAssistantsOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIAssistantsOptions", covariant=True, @@ -206,11 +206,11 @@ TOpenAIAssistantsOptions = TypeVar( class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIAssistantsOptions], - FunctionInvocationLayer[TOpenAIAssistantsOptions], - ChatTelemetryLayer[TOpenAIAssistantsOptions], - BaseChatClient[TOpenAIAssistantsOptions], - Generic[TOpenAIAssistantsOptions], + ChatMiddlewareLayer[OpenAIAssistantsOptionsT], + FunctionInvocationLayer[OpenAIAssistantsOptionsT], + ChatTelemetryLayer[OpenAIAssistantsOptionsT], + BaseChatClient[OpenAIAssistantsOptionsT], + Generic[OpenAIAssistantsOptionsT], ): """OpenAI Assistants client with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 4ca47a4481..b3d54f251e 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -65,7 +65,7 @@ __all__ = ["OpenAIChatClient", "OpenAIChatOptions"] logger = get_logger("agent_framework.openai") -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region OpenAI Chat Options TypedDict @@ -85,7 +85,7 @@ class Prediction(TypedDict, total=False): content: str | list[PredictionTextContent] -class OpenAIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OpenAIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """OpenAI-specific chat options dict. Extends ChatOptions with options specific to OpenAI's Chat Completions API. @@ -124,7 +124,7 @@ class OpenAIChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], to prediction: Prediction -TOpenAIChatOptions = TypeVar("TOpenAIChatOptions", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type] +OpenAIChatOptionsT = TypeVar("OpenAIChatOptionsT", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type] OPTION_TRANSLATIONS: dict[str, str] = { "model_id": "model", @@ -136,8 +136,8 @@ OPTION_TRANSLATIONS: dict[str, str] = { # region Base Client class RawOpenAIChatClient( # type: ignore[misc] OpenAIBase, - BaseChatClient[TOpenAIChatOptions], - Generic[TOpenAIChatOptions], + BaseChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """Raw OpenAI Chat completion class without middleware, telemetry, or function invocation. @@ -593,11 +593,11 @@ class RawOpenAIChatClient( # type: ignore[misc] class OpenAIChatClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIChatOptions], - FunctionInvocationLayer[TOpenAIChatOptions], - ChatTelemetryLayer[TOpenAIChatOptions], - RawOpenAIChatClient[TOpenAIChatOptions], - Generic[TOpenAIChatOptions], + ChatMiddlewareLayer[OpenAIChatOptionsT], + FunctionInvocationLayer[OpenAIChatOptionsT], + ChatTelemetryLayer[OpenAIChatOptionsT], + RawOpenAIChatClient[OpenAIChatOptionsT], + Generic[OpenAIChatOptionsT], ): """OpenAI Chat completion class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index b2b7451918..69da1df531 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -124,10 +124,10 @@ class StreamOptions(TypedDict, total=False): """Whether to include usage statistics in stream events.""" -TResponseFormat = TypeVar("TResponseFormat", bound=BaseModel | None, default=None) +ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel | None, default=None) -class OpenAIResponsesOptions(ChatOptions[TResponseFormat], Generic[TResponseFormat], total=False): +class OpenAIResponsesOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT], total=False): """OpenAI Responses API-specific chat options. Extends ChatOptions with options specific to OpenAI's Responses API. @@ -191,8 +191,8 @@ class OpenAIResponsesOptions(ChatOptions[TResponseFormat], Generic[TResponseForm - 'disabled': Fail with 400 error if exceeds context""" -TOpenAIResponsesOptions = TypeVar( - "TOpenAIResponsesOptions", +OpenAIResponsesOptionsT = TypeVar( + "OpenAIResponsesOptionsT", bound=TypedDict, # type: ignore[valid-type] default="OpenAIResponsesOptions", covariant=True, @@ -207,8 +207,8 @@ TOpenAIResponsesOptions = TypeVar( class RawOpenAIResponsesClient( # type: ignore[misc] OpenAIBase, - BaseChatClient[TOpenAIResponsesOptions], - Generic[TOpenAIResponsesOptions], + BaseChatClient[OpenAIResponsesOptionsT], + Generic[OpenAIResponsesOptionsT], ): """Raw OpenAI Responses client without middleware, telemetry, or function invocation. @@ -1437,11 +1437,11 @@ class RawOpenAIResponsesClient( # type: ignore[misc] class OpenAIResponsesClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[TOpenAIResponsesOptions], - FunctionInvocationLayer[TOpenAIResponsesOptions], - ChatTelemetryLayer[TOpenAIResponsesOptions], - RawOpenAIResponsesClient[TOpenAIResponsesOptions], - Generic[TOpenAIResponsesOptions], + ChatMiddlewareLayer[OpenAIResponsesOptionsT], + FunctionInvocationLayer[OpenAIResponsesOptionsT], + ChatTelemetryLayer[OpenAIResponsesOptionsT], + RawOpenAIResponsesClient[OpenAIResponsesOptionsT], + Generic[OpenAIResponsesOptionsT], ): """OpenAI Responses client class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/core/tests/core/conftest.py b/python/packages/core/tests/core/conftest.py index 7f987ca226..7cb5e63549 100644 --- a/python/packages/core/tests/core/conftest.py +++ b/python/packages/core/tests/core/conftest.py @@ -27,7 +27,7 @@ from agent_framework import ( ToolProtocol, tool, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework.observability import ChatTelemetryLayer if sys.version_info >= (3, 12): @@ -135,11 +135,11 @@ class MockChatClient: class MockBaseChatClient( - ChatMiddlewareLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - BaseChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + BaseChatClient[OptionsCoT], + Generic[OptionsCoT], ): """Mock implementation of a full-featured ChatClient.""" diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 107978e36b..38bcbdd855 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -232,7 +232,7 @@ class PropertySchema(SerializationMixin): return json_schema -TConnection = TypeVar("TConnection", bound="Connection") +ConnectionT = TypeVar("ConnectionT", bound="Connection") class Connection(SerializationMixin): @@ -250,12 +250,12 @@ class Connection(SerializationMixin): @classmethod def from_dict( - cls: type[TConnection], + cls: type[ConnectionT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None, - ) -> TConnection: + ) -> ConnectionT: """Create a Connection instance from a dictionary, dispatching to the appropriate subclass.""" # Only dispatch if we're being called on the base Connection class if cls is not Connection: @@ -507,7 +507,7 @@ class AgentDefinition(SerializationMixin): return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] -TTool = TypeVar("TTool", bound="Tool") +ToolT = TypeVar("ToolT", bound="Tool") class Tool(SerializationMixin): @@ -538,8 +538,8 @@ class Tool(SerializationMixin): @classmethod def from_dict( - cls: type[TTool], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None - ) -> TTool: + cls: type[ToolT], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> ToolT: """Create a Tool instance from a dictionary, dispatching to the appropriate subclass.""" # Only dispatch if we're being called on the base Tool class if cls is not Tool: diff --git a/python/packages/devui/tests/devui/conftest.py b/python/packages/devui/tests/devui/conftest.py index b229b0e9e6..4d6f818795 100644 --- a/python/packages/devui/tests/devui/conftest.py +++ b/python/packages/devui/tests/devui/conftest.py @@ -29,7 +29,7 @@ from agent_framework import ( Content, ResponseStream, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework._workflows._agent_executor import AgentExecutorResponse from agent_framework._workflows._events import ( WorkflowErrorDetails, @@ -88,7 +88,7 @@ class MockChatClient: yield ChatResponseUpdate(contents=[Content.from_text(text="test streaming response")], role="assistant") -class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): +class MockBaseChatClient(BaseChatClient[OptionsCoT], Generic[OptionsCoT]): """Full BaseChatClient mock with middleware support. Use this when testing features that require the full BaseChatClient interface. diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py index e4516f1ce3..03e26784cc 100644 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ b/python/packages/durabletask/tests/test_durable_entities.py @@ -26,7 +26,7 @@ from agent_framework_durabletask import ( ) from agent_framework_durabletask._entities import DurableTaskEntityStateProvider -TState = TypeVar("TState") +StateT = TypeVar("StateT") class MockEntityContext: @@ -37,8 +37,8 @@ class MockEntityContext: def get_state( self, - intended_type: type[TState] | None = None, - default: TState | None = None, + intended_type: type[StateT] | None = None, + default: StateT | None = None, ) -> Any: del intended_type if self._state is None: diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 0ee6ce4ab0..5cf9e8c85d 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -38,13 +38,13 @@ __all__ = [ "FoundryLocalSettings", ] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Foundry Local Chat Options TypedDict -class FoundryLocalChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Azure Foundry Local (local model deployment) chat options dict. Extends base ChatOptions for local model inference via Foundry Local. @@ -104,8 +104,8 @@ FOUNDRY_LOCAL_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to OpenAI API parameter names (for compatibility).""" -TFoundryLocalChatOptions = TypeVar( - "TFoundryLocalChatOptions", +FoundryLocalChatOptionsT = TypeVar( + "FoundryLocalChatOptionsT", bound=TypedDict, # type: ignore[valid-type] default="FoundryLocalChatOptions", covariant=True, @@ -137,11 +137,11 @@ class FoundryLocalSettings(AFBaseSettings): class FoundryLocalClient( - ChatMiddlewareLayer[TFoundryLocalChatOptions], - FunctionInvocationLayer[TFoundryLocalChatOptions], - ChatTelemetryLayer[TFoundryLocalChatOptions], - RawOpenAIChatClient[TFoundryLocalChatOptions], - Generic[TFoundryLocalChatOptions], + ChatMiddlewareLayer[FoundryLocalChatOptionsT], + FunctionInvocationLayer[FoundryLocalChatOptionsT], + ChatTelemetryLayer[FoundryLocalChatOptionsT], + RawOpenAIChatClient[FoundryLocalChatOptionsT], + Generic[FoundryLocalChatOptionsT], ): """Foundry Local Chat completion class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 46a92a6dc9..06fad5d126 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -91,15 +91,15 @@ class GitHubCopilotOptions(TypedDict, total=False): """ -TOptions = TypeVar( - "TOptions", +OptionsT = TypeVar( + "OptionsT", bound=TypedDict, # type: ignore[valid-type] default="GitHubCopilotOptions", covariant=True, ) -class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): +class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): """A GitHub Copilot Agent. This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities @@ -156,7 +156,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None = None, - default_options: TOptions | None = None, + default_options: OptionsT | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -225,7 +225,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): self._default_options = opts self._started = False - async def __aenter__(self) -> GitHubCopilotAgent[TOptions]: + async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]: """Start the agent when entering async context.""" await self.start() return self @@ -282,7 +282,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): *, stream: Literal[False] = False, thread: AgentThread | None = None, - options: TOptions | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @@ -293,7 +293,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): *, stream: Literal[True], thread: AgentThread | None = None, - options: TOptions | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... @@ -303,7 +303,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): *, stream: bool = False, thread: AgentThread | None = None, - options: TOptions | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -344,7 +344,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - options: TOptions | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AgentResponse: """Non-streaming implementation of run.""" @@ -392,7 +392,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[TOptions]): messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, *, thread: AgentThread | None = None, - options: TOptions | None = None, + options: OptionsT | None = None, **kwargs: Any, ) -> AsyncIterable[AgentResponseUpdate]: """Internal method to stream updates from GitHub Copilot. diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 684e5c6d9d..8ffba3be3e 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -65,13 +65,13 @@ else: __all__ = ["OllamaChatClient", "OllamaChatOptions"] -TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None) +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) # region Ollama Chat Options TypedDict -class OllamaChatOptions(ChatOptions[TResponseModel], Generic[TResponseModel], total=False): +class OllamaChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Ollama-specific chat options dict. Extends base ChatOptions with Ollama-specific parameters. @@ -272,7 +272,7 @@ OLLAMA_MODEL_OPTION_TRANSLATIONS: dict[str, str] = { } """Maps ChatOptions keys to Ollama model option parameter names.""" -TOllamaChatOptions = TypeVar("TOllamaChatOptions", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type] +OllamaChatOptionsT = TypeVar("OllamaChatOptionsT", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type] # endregion @@ -291,10 +291,10 @@ logger = get_logger("agent_framework.ollama") class OllamaChatClient( - ChatMiddlewareLayer[TOllamaChatOptions], - FunctionInvocationLayer[TOllamaChatOptions], - ChatTelemetryLayer[TOllamaChatOptions], - BaseChatClient[TOllamaChatOptions], + ChatMiddlewareLayer[OllamaChatOptionsT], + FunctionInvocationLayer[OllamaChatOptionsT], + ChatTelemetryLayer[OllamaChatOptionsT], + BaseChatClient[OllamaChatOptionsT], ): """Ollama Chat completion class with middleware, telemetry, and function invocation support.""" diff --git a/python/packages/purview/agent_framework_purview/_models.py b/python/packages/purview/agent_framework_purview/_models.py index 4e14147ac5..0e4985689e 100644 --- a/python/packages/purview/agent_framework_purview/_models.py +++ b/python/packages/purview/agent_framework_purview/_models.py @@ -177,7 +177,7 @@ def translate_activity(activity: Activity) -> ProtectionScopeActivities: # Simple value models # -------------------------------------------------------------------------------------- -TAliasSerializable = TypeVar("TAliasSerializable", bound="_AliasSerializable") +AliasSerializableT = TypeVar("AliasSerializableT", bound="_AliasSerializable") class _AliasSerializable(SerializationMixin): @@ -232,7 +232,7 @@ class _AliasSerializable(SerializationMixin): return json.dumps(self.model_dump(by_alias=by_alias, exclude_none=exclude_none, **kwargs)) @classmethod - def model_validate(cls: type[TAliasSerializable], value: MutableMapping[str, Any]) -> TAliasSerializable: # type: ignore[name-defined] + def model_validate(cls: type[AliasSerializableT], value: MutableMapping[str, Any]) -> AliasSerializableT: # type: ignore[name-defined] return cls(**value) # ------------------------------------------------------------------ diff --git a/python/samples/getting_started/chat_client/custom_chat_client.py b/python/samples/getting_started/chat_client/custom_chat_client.py index af56e5456f..149b7230e1 100644 --- a/python/samples/getting_started/chat_client/custom_chat_client.py +++ b/python/samples/getting_started/chat_client/custom_chat_client.py @@ -17,7 +17,7 @@ from agent_framework import ( ResponseStream, Role, ) -from agent_framework._clients import TOptions_co +from agent_framework._clients import OptionsCoT from agent_framework.observability import ChatTelemetryLayer if sys.version_info >= (3, 13): @@ -38,7 +38,7 @@ middleware, telemetry, and function invocation layers explicitly. """ -class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): +class EchoingChatClient(BaseChatClient[OptionsCoT], Generic[OptionsCoT]): """A custom chat client that echoes messages back with modifications. This demonstrates how to implement a custom chat client by extending BaseChatClient @@ -112,11 +112,11 @@ class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]): class EchoingChatClientWithLayers( # type: ignore[misc,type-var] - ChatMiddlewareLayer[TOptions_co], - ChatTelemetryLayer[TOptions_co], - FunctionInvocationLayer[TOptions_co], - EchoingChatClient[TOptions_co], - Generic[TOptions_co], + ChatMiddlewareLayer[OptionsCoT], + ChatTelemetryLayer[OptionsCoT], + FunctionInvocationLayer[OptionsCoT], + EchoingChatClient[OptionsCoT], + Generic[OptionsCoT], ): """Echoing chat client that explicitly composes middleware, telemetry, and function layers.""" diff --git a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py b/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py index a5b0b3d7a8..e82cbdb2be 100644 --- a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py @@ -106,9 +106,15 @@ async def main(scenario: Literal["chat_client", "chat_client_stream", "tool", "a # Create custom OTLP exporters with specific configuration # Note: You need to install opentelemetry-exporter-otlp-proto-grpc or -http separately try: - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # pyright: ignore[reportMissingImports] + OTLPLogExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # pyright: ignore[reportMissingImports] + OTLPMetricExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # pyright: ignore[reportMissingImports] + OTLPSpanExporter, + ) # Create exporters with custom configuration # These will be added to any exporters configured via environment variables From 84cb09cb6814befefe599c3c953d9dd8861dd542 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:43:33 -0800 Subject: [PATCH 09/10] Python: Add streaming support for code interpreter deltas (#3775) * add streaming support for code interpreter deltas * addressed copilot comments * mypy fix --- .../openai/_responses_client.py | 44 ++++++++++++++ .../openai/test_openai_responses_client.py | 58 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 69da1df531..74f835f310 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1164,6 +1164,50 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "response.reasoning_summary_text.done": contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) + case "response.code_interpreter_call_code.delta": + call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id + ci_additional_properties = { + "output_index": event.output_index, + "sequence_number": event.sequence_number, + "item_id": event.item_id, + } + contents.append( + Content.from_code_interpreter_tool_call( + call_id=call_id, + inputs=[ + Content.from_text( + text=event.delta, + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ], + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ) + metadata.update(self._get_metadata_from_response(event)) + case "response.code_interpreter_call_code.done": + call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id + ci_additional_properties = { + "output_index": event.output_index, + "sequence_number": event.sequence_number, + "item_id": event.item_id, + } + contents.append( + Content.from_code_interpreter_tool_call( + call_id=call_id, + inputs=[ + Content.from_text( + text=event.code, + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ], + raw_representation=event, + additional_properties=ci_additional_properties, + ) + ) + metadata.update(self._get_metadata_from_response(event)) case "response.created": response_id = event.response.id conversation_id = self._get_conversation_id(event.response, options.get("store")) diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 88a20285d2..d4259f22ad 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -1715,6 +1715,64 @@ def test_parse_chunk_from_openai_code_interpreter() -> None: assert any(out.type == "uri" and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs) +def test_parse_chunk_from_openai_code_interpreter_delta() -> None: + """Test _parse_chunk_from_openai with code_interpreter_call_code delta events.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + # Test delta event + mock_delta_event = MagicMock() + mock_delta_event.type = "response.code_interpreter_call_code.delta" + mock_delta_event.item_id = "ci_123" + mock_delta_event.delta = "import pandas as pd\n" + mock_delta_event.output_index = 0 + mock_delta_event.sequence_number = 1 + mock_delta_event.call_id = None # Ensure fallback to item_id + mock_delta_event.id = None + + result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert result.contents[0].type == "code_interpreter_tool_call" + assert result.contents[0].call_id == "ci_123" + assert result.contents[0].inputs + assert result.contents[0].inputs[0].type == "text" + assert result.contents[0].inputs[0].text == "import pandas as pd\n" + # Verify additional_properties for stream ordering + assert result.contents[0].additional_properties["output_index"] == 0 + assert result.contents[0].additional_properties["sequence_number"] == 1 + assert result.contents[0].additional_properties["item_id"] == "ci_123" + + +def test_parse_chunk_from_openai_code_interpreter_done() -> None: + """Test _parse_chunk_from_openai with code_interpreter_call_code done event.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + # Test done event + mock_done_event = MagicMock() + mock_done_event.type = "response.code_interpreter_call_code.done" + mock_done_event.item_id = "ci_456" + mock_done_event.code = "import pandas as pd\ndf = pd.DataFrame({'a': [1, 2, 3]})\nprint(df)" + mock_done_event.output_index = 0 + mock_done_event.sequence_number = 5 + mock_done_event.call_id = None # Ensure fallback to item_id + mock_done_event.id = None + + result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) # type: ignore + assert len(result.contents) == 1 + assert result.contents[0].type == "code_interpreter_tool_call" + assert result.contents[0].call_id == "ci_456" + assert result.contents[0].inputs + assert result.contents[0].inputs[0].type == "text" + assert "import pandas as pd" in result.contents[0].inputs[0].text + # Verify additional_properties for stream ordering + assert result.contents[0].additional_properties["output_index"] == 0 + assert result.contents[0].additional_properties["sequence_number"] == 5 + assert result.contents[0].additional_properties["item_id"] == "ci_456" + + def test_parse_chunk_from_openai_reasoning() -> None: """Test _parse_chunk_from_openai with reasoning content.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") From 32ba81e990508437fc3d0fe84d62566a10b4f1ba Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:14:28 -0800 Subject: [PATCH 10/10] Python: Add documentation for declaration-only tools and middleware ordering (#3774) * added explanation doctrings * copilot comments --- .../packages/core/agent_framework/_tools.py | 37 ++++++++++++++++--- .../agent_and_run_level_middleware.py | 21 ++++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index da490d772e..0d31471aba 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -640,16 +640,25 @@ class FunctionTool(BaseTool, Generic[ArgsT, ReturnT]): name: The name of the function. description: A description of the function. approval_mode: Whether or not approval is required to run this tool. - Default is that approval is required. + Default is that approval is NOT required (``"never_require"``). max_invocations: The maximum number of times this function can be invoked. If None, there is no limit. Should be at least 1. max_invocation_exceptions: The maximum number of exceptions allowed during invocations. If None, there is no limit. Should be at least 1. additional_properties: Additional properties to set on the function. - func: The function to wrap. + func: The function to wrap. When ``None``, creates a declaration-only tool + that has no implementation. Declaration-only tools are useful when you want + the agent to reason about tool usage without executing them, or when the + actual implementation exists elsewhere (e.g., client-side rendering). input_model: The Pydantic model that defines the input parameters for the function. This can also be a JSON schema dictionary. - If not provided, it will be inferred from the function signature. + If not provided and ``func`` is not ``None``, it will be inferred from + the function signature. When ``func`` is ``None`` and ``input_model`` is + not provided, the tool will use an empty input model (no parameters) in + its JSON schema. For declaration-only tools that should declare + parameters, explicitly provide ``input_model`` (either a Pydantic + ``BaseModel`` or a JSON schema dictionary) so the model can reason about + the expected arguments. **kwargs: Additional keyword arguments. """ super().__init__( @@ -1286,7 +1295,11 @@ def tool( to bypass automatic inference from the function signature. Args: - func: The function to decorate. + func: The function to decorate. This parameter enables the decorator to be used + both with and without parentheses: ``@tool`` directly decorates the function, + while ``@tool()`` or ``@tool(name="custom")`` returns a decorator. For + declaration-only tools (no implementation), use :class:`FunctionTool` directly + with ``func=None``—see the example below. Keyword Args: name: The name of the function. If not provided, the function's ``__name__`` @@ -1301,7 +1314,7 @@ def tool( When provided, the schema is used instead of inferring one from the function's signature. Defaults to ``None`` (infer from signature). approval_mode: Whether or not approval is required to run this tool. - Default is that approval is required. + Default is that approval is NOT required (``"never_require"``). max_invocations: The maximum number of times this function can be invoked. If None, there is no limit, should be at least 1. max_invocation_exceptions: The maximum number of exceptions allowed during invocations. @@ -1369,6 +1382,20 @@ def tool( '''Get weather for a location.''' return f"Weather in {location}: 22 {unit}" + + # Declaration-only tool (no implementation) + # Use FunctionTool directly when you need a tool declaration without + # an executable function. The agent can request this tool, but it won't + # be executed automatically. Useful for testing agent reasoning or when + # the implementation is handled externally (e.g., client-side rendering). + from agent_framework import FunctionTool + + declaration_only_tool = FunctionTool( + name="get_current_time", + description="Get the current time in ISO 8601 format.", + func=None, # Explicitly no implementation - makes declaration_only=True + ) + """ def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> FunctionTool[Any, ReturnT]: diff --git a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py index b76e0ac520..70408472ad 100644 --- a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py +++ b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py @@ -31,7 +31,26 @@ The example shows: 3. Run-level context middleware for specific use cases (high priority, debugging) 4. Run-level caching middleware for expensive operations -Execution order: Agent middleware (outermost) -> Run middleware (innermost) -> Agent execution +Agent Middleware Execution Order: + When both agent-level and run-level *agent* middleware are configured, they execute + in this order: + + 1. Agent-level middleware (outermost) - executes first, in the order they were registered + 2. Run-level middleware (innermost) - executes next, in the order they were passed to run() + 3. Agent execution - the actual agent logic runs last + + For example, with agent middleware [A1, A2] and run middleware [R1, R2]: + Request -> A1 -> A2 -> R1 -> R2 -> Agent -> R2 -> R1 -> A2 -> A1 -> Response + + This means: + - Agent middleware wraps ALL run middleware and the agent + - Run middleware wraps only the agent for that specific run + - Each middleware can modify the context before AND after calling next() + + Note: Function and chat middleware (e.g., ``function_logging_middleware``) execute + during tool invocation *inside* the agent execution, not in the outer agent-middleware + chain shown above. They follow the same ordering principle: agent-level function/chat + middleware runs before run-level function/chat middleware. """