From 1e1eda65ce928651c05e9ee6ed747a282395533f Mon Sep 17 00:00:00 2001 From: Yung-Shin Lin Date: Tue, 28 Apr 2026 13:55:59 -0700 Subject: [PATCH 1/3] [Python] Add agent-framework-azure-ai-contentunderstanding package (#4829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add agent-framework-azure-contentunderstanding package Add Azure Content Understanding integration as a context provider for the Agent Framework. The package automatically analyzes file attachments (documents, images, audio, video) using Azure CU and injects structured results (markdown, fields) into the LLM context. Key features: - Multi-document session state with status tracking (pending/ready/failed) - Configurable timeout with async background fallback for large files - Output filtering via AnalysisSection enum - Auto-registered list_documents() and get_analyzed_document() tools - Supports all CU modalities: documents, images, audio, video - Content limits enforcement (pages, file size, duration) - Binary stripping of supported files from input messages Public API: - ContentUnderstandingContextProvider (main class) - AnalysisSection (output section selector enum) - ContentLimits (configurable limits dataclass) Tests: 46 unit tests, 91% coverage, all linting and type checks pass. * fix: update CU fixtures with real API data, fix test assertions - Replace synthetic fixtures with real CU API responses (sanitized) - Update test assertions to match real data (Contoso vs CONTOSO, TotalAmount vs InvoiceTotal, field values from real analysis) - Add --pre install note in README (preview package) - Document unenforced ContentLimits fields (max_pages, duration) * chore: add connector .gitignore, update uv.lock * refactor: rename to azure-ai-contentunderstanding, fix CI issues Align naming with Azure SDK convention and AF pattern: - Directory: azure-contentunderstanding -> azure-ai-contentunderstanding - PyPI: agent-framework-azure-contentunderstanding -> agent-framework-azure-ai-contentunderstanding - Module: agent_framework_azure_contentunderstanding -> agent_framework_azure_ai_contentunderstanding CI fixes: - Inline conftest helpers to avoid cross-package import collision in xdist - Remove PyPI badge and dead API reference link from README (package not published yet) * feat: add samples (document_qa, invoice_processing, multimodal_chat) - document_qa.py: Single PDF upload, CU context provider, follow-up Q&A - invoice_processing.py: Structured field extraction with prebuilt-invoice - multimodal_chat.py: Multi-file session with status tracking - Add ruff per-file-ignores for samples/ directory - Update README with samples section, env vars, and run instructions * feat: add remaining samples (devui_multimodal_agent, large_doc_file_search) - S3: devui_multimodal_agent/ — DevUI web UI with CU-powered file analysis - S4: large_doc_file_search.py — CU extraction + OpenAI vector store RAG - Update README and samples/README.md with all 5 samples * feat: add file_search integration for large document RAG Add FileSearchConfig — when provided, CU-extracted markdown is automatically uploaded to an OpenAI vector store and a file_search tool is registered on the context. This enables token-efficient RAG retrieval for large documents without users needing to manage vector stores manually. - FileSearchConfig dataclass (openai_client, vector_store_name) - Auto-create vector store, upload markdown, register file_search tool - Auto-cleanup on close() - When file_search is enabled, skip full content injection (use RAG instead) - Update large_doc_file_search sample to use the integration - 4 new tests (50 total, 90% coverage) * fix: add key-based auth support to all samples Follow established AF pattern: check for API key env var first, fall back to AzureCliCredential. Supports AZURE_OPENAI_API_KEY and AZURE_CONTENTUNDERSTANDING_API_KEY environment variables. * FEATURE(python): add analyzer auto-detection, file_search RAG, and lazy init _context_provider.py: - Make analyzer_id optional (default None) with auto-detection by media type prefix: audio->audioSearch, video->videoSearch, else documentSearch - Add _ensure_initialized() for lazy client creation in before_run() - Add FileSearchConfig-based vector store upload - Fix: background-completed docs in file_search mode now upload to vector store instead of injecting full markdown into context messages - Add _pending_uploads queue for deferred vector store uploads devui_file_search_agent/ (new sample): - DevUI agent combining CU extraction + OpenAI file_search RAG azure_responses_agent (existing sample fix): - Add AzureCliCredential support and AZURE_AI_PROJECT_ENDPOINT fallback Tests (19 new), Docs updated (AGENTS.md, README.md) * feat(cu): MIME sniffing, media-aware formatting, unified timeout, vector store expiration - Add three-layer MIME detection (fast path → filetype binary sniff → filename fallback) to handle unreliable upstream MIME types (e.g. mp4 sent as application/octet-stream). Adds filetype>=1.2,<2 dependency. - Media-aware output formatting: video shows duration/resolution + all fields as JSON; audio promotes Summary as prose; document unchanged. - Unified timeout for all media types (removed file_search special-case that waited indefinitely for video/audio). All files use max_wait with background polling fallback. - Vector store created with expires_after=1 day as crash safety net. - Add 8 MIME sniffing tests (TestMimeSniffing class). * fix: merge all CU content segments for video/audio analysis CU's prebuilt-videoSearch and prebuilt-audioSearch analyzers split long media files into multiple `contents[]` segments. Previously, `_extract_sections()` only read `contents[0]`, causing truncated duration, missing transcript, and incomplete fields for any video/audio longer than a single scene. Now iterates all segments and merges: - duration: global min(startTimeMs) → max(endTimeMs) - markdown: concatenated with `---` separators - fields: same-named fields collected into per-segment list - metadata (kind, resolution): taken from first segment Single-segment results (documents, short audio) are unaffected. Update test fixture to realistic 3-segment video structure and expand assertions to verify multi-segment merging. Add documentation for multi-segment processing and speaker diarization limitation. * refactor: improve CU context provider docs and remove ContentLimits - Improve class docstring: clarify endpoint (Azure AI Foundry URL with example), credential (AzureKeyCredential vs Entra ID), and analyzer_id (prebuilt/custom with auto-selection behavior and reference links) - Add SUPPORTED_MEDIA_TYPES comments explaining MIME-based matching behavior and add missing file types per CU service docs - Use namespaced logger to align with other packages - Remove ContentLimits and related code/tests - Rename DEFAULT_MAX_WAIT to DEFAULT_MAX_WAIT_SECONDS for clarity * feat: support user-provided vector store in FileSearchConfig - Add vector_store_id field to FileSearchConfig (None = auto-create) - Track _owns_vector_store to only delete auto-created stores on close() - Remove vector_store_name; use internal _DEFAULT_VECTOR_STORE_NAME - Add inline comments for private state fields - Document output_sections default in docstring - Update AGENTS.md, samples, and tests * fix: remove ContentLimits from README code block * refactor: create CU client in __init__ instead of __aenter__ Follow Azure AI Search provider pattern: create the client eagerly in __init__, make __aenter__ a no-op. This ensures __aexit__/close() is always safe to call and eliminates the _ensure_initialized() workaround. * docs: add file_search param to class docstring * feat: introduce FileSearchBackend abstraction for cross-client support Replace direct OpenAI client usage with FileSearchBackend ABC: - OpenAIFileSearchBackend: for OpenAIChatClient (Responses API) - FoundryFileSearchBackend: for FoundryChatClient (Azure Foundry) - Shared base _OpenAICompatBackend for common vector store CRUD FileSearchConfig now takes a backend instead of openai_client. Factory methods from_openai() and from_foundry() for convenience. BREAKING: FileSearchConfig(openai_client=...) -> FileSearchConfig.from_openai(...) * refactor: FileSearchBackend abstraction + caller-owned vector store * fix: file_search reliability and sample improvements - Poll vector store indexing (create_and_poll) to ensure file_search returns results immediately after upload - Set status to failed when vector store upload fails - Skip get_analyzed_document tool in file_search mode to prevent LLM from bypassing RAG - Simplify sample auth: single credential, direct parameters - Use from_foundry backend for Foundry project endpoints * perf: set max_num_results=10 for file_search to reduce token usage * fix: move import to top of file (E402 lint) * chore: remove unused imports * fix: align azure-ai-contentunderstanding with MAF coding conventions - Add module-level docstrings to __init__.py and _context_provider.py - Use Self return type for __aenter__ (with typing_extensions fallback) - Use explicit typed params for __aexit__ signature - Add sync TokenCredential to AzureCredentialTypes union - Pass AGENT_FRAMEWORK_USER_AGENT to ContentUnderstandingClient - Remove unused ContentLimits from public API and tests - Fix FileSearchConfig tests to match refactored backend API - Fix lifecycle tests to match eager client initialization * refactor: improve CU context provider API surface and fix CI - Refactor _analyze_file to return DocumentEntry instead of mutating dict - Remove TokenCredential from AzureCredentialTypes (fixes mypy/pyright CI) - Remove OpenAIFileSearchBackend/FoundryFileSearchBackend from public API (internal to FileSearchConfig factory methods) - Remove DocumentStatus from public exports (implementation detail) - Update file_search comments to reflect backend-agnostic design - Add DocumentStatus enum, analysis/upload duration tracking - Add combined timeout for CU analysis + vector store upload * fix: improve file_search samples and move tool guidelines to context provider - Delete redundant devui_file_search_agent sample (duplicate of azure_openai variant) - Move tool usage guidelines from sample agent instructions into context provider (extend_instructions in step 6, applied automatically for all file_search users) - Fix file_search purpose: use from_foundry() for Azure OpenAI (purpose="assistants") - Add filename hint in upload instructions for targeted file_search queries - Reduce max_num_results from 10 to 3 in both devui samples - Simplify agent instructions in both samples (remove tool-specific guidance) * feat: improve source_id, integration tests, and content assertions - Rename DEFAULT_SOURCE_ID to "azure_ai_contentunderstanding" (matches azure_ai_search convention) - Improve source_id docstring to describe default value - Clarify _detect_and_strip_files docstring (CU-supported files) - Add invoice.pdf test fixture from Azure CU samples repo - Refactor integration tests to use invoice.pdf directly (assert instead of skip when fixture missing) - Add URI content test (Content.from_uri with external URL) - Add "CONTOSO LTD." content assertion to all integration tests - Use max_wait=None in integration tests (wait until complete) * feat: reject duplicate filenames, add integration tests and sample comments - Reject duplicate document keys in before_run (skip + warn LLM to rename) - Update _derive_doc_key docstring to document uniqueness constraint - Add unit tests for duplicate filename rejection (cross-turn and same-turn) - Add integration test for data URI content (from_uri with base64) - Add integration test for background analysis (max_wait timeout + resolve) - Add filename recommendation comments to all samples' Content.from_data() * chore: improve doc key derivation, comments, and README - Replace hash-based doc key with uuid4 for anonymous uploads (O(1), no payload traversal) - Remove hashlib import (no longer needed) - Add File Naming section to README (filename importance, duplicate rejection) - Improve inline comments (_derive_doc_key, _extract_binary, URL parsing) * test: strengthen _format_result assertions with exact expected strings - Replace loose 'in' checks with exact 'assert formatted == expected' for both multi-segment and single-segment format tests - Add object-type fields (ShippingAddress, Speakers) to test data to cover nested dict/list serialization - Add position-based ordering assertions to verify structural correctness (header -> markdown -> fields across segments) * refactor: move invoice.pdf to shared sample_assets directory - Move invoice.pdf from tests/cu/test_data/ to python/samples/shared/sample_assets/ as single source of truth - Add INVOICE_PDF_PATH constant in test_integration.py pointing to the shared location - Update document_qa.py, invoice_processing.py, large_doc_file_search.py to use invoice.pdf instead of sample.pdf * refactor: reorganize samples into numbered dirs and simplify auth - Move script samples into 01-get-started/ with numbered prefixes (01_document_qa, 02_multimodal_chat, 03_invoice_processing, 04_large_doc_file_search) - Move devui samples into 02-devui/ with 01-multimodal_agent and 02-file_search_agent/{azure_openai_backend,foundry_backend} - Move invoice.pdf to CU package-local samples/shared/sample_assets/ - Replace kwargs dicts with direct constructor calls; support both API key (AZURE_OPENAI_API_KEY) and AzureCliCredential - Update README sample table with new paths * fix: resolve CI lint errors (D205, RUF001, E501) - Fix D205: single-line docstring summary for _detect_and_strip_files - Fix RUF001: replace EN DASH with HYPHEN-MINUS in segment headers - Fix E501: wrap long assertion lines in tests - Also includes samples reorg and auth simplification * refactor: overhaul samples — FoundryChatClient, sessions, remove get_analyzed_document Samples: - Switch all samples from deprecated AzureOpenAIResponsesClient to FoundryChatClient - Add 02_multi_turn_session.py showing AgentSession persistence across turns - Rewrite 03_multimodal_chat.py with real PDF + audio + video (parallel analysis), per-modality follow-ups, cross-document question, elapsed time, user prompts, and input token counts - Renumber: 02->03 multimodal, 03->04 invoice, 04->05 file_search Context provider: - Remove get_analyzed_document tool -- full content is in conversation history via InMemoryHistoryProvider, no retrieval tool needed - Remove follow-up turn instructions about tools - Only list_documents tool remains (for status queries) - Update README to reflect tool removal * feat: add 05_background_analysis sample and fix 04 session/max_wait - Add 05_background_analysis.py demonstrating non-blocking CU analysis with max_wait=1s, status tracking via list_documents(), and automatic background task resolution on subsequent turns - Fix 04_invoice_processing.py: add max_wait=None and AgentSession - Rename 05→06 large_doc_file_search - Update README sample table * docs: update README and fix sample 06 README: - Switch Quick Start from AzureOpenAIResponsesClient to FoundryChatClient - Add AgentSession to Quick Start example - Fix status values: pending -> analyzing/uploading/ready/failed - Fix env var: AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME -> AZURE_OPENAI_DEPLOYMENT_NAME - Update samples section with new paths, link to samples/README.md - Update multi-segment description to reflect per-segment fields Sample 06: - Fix from_openai -> from_foundry for Azure endpoints - Add AgentSession and max_wait=None * docs: rewrite README — concise format, prerequisites, CU link * fix: resolve pyright errors in _format_result segment cast * docs: add numbered section comments and fresh sample output to all samples - Add numbered section comments (# 1. ..., # 2. ...) per SAMPLE_GUIDELINES - Re-run all 6 samples and update expected output with real results - Fix duplicate sample output blocks in 04 and 05 - Update README code example to use public invoice URL * feat: add load_settings support for env var configuration - Make endpoint optional in constructor — auto-loads from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var via load_settings() - Add ContentUnderstandingSettings TypedDict - Add env_file_path/env_file_encoding params for .env file support - Add 4 unit tests: env var loading, explicit override, missing endpoint error, missing credential error - Update README with env var auto-resolution docs - Follows framework convention used by all other packages * docs: polish README — fix duplicate env var, add Next steps, service limits link * chore: trim invoice fixture from 199K to 33 lines Keep only VendorName, InvoiceTotal, DueDate, InvoiceDate, InvoiceId fields and first 500 chars of markdown. Strip spans/source/coordinates. Reduces fixture from 6.6MB to 1.2KB. * feat: per-file analyzer_id override via additional_properties - Read analyzer_id from Content.additional_properties for per-file override - Resolution order: per-file > provider-level > auto-detect by media type - Update class docstring documenting filename and analyzer_id properties - Update sample 04 to demonstrate per-file override (prebuilt-invoice) - Add unit test for per-file analyzer override * Trim PDF test fixture and clarify unique filename requirement - Trim analyze_pdf_result.json from 4427 to 23 lines by removing pages, words, lines, paragraphs, sections, spans, and source fields that are not used by any unit test. - Add docstring note that filename must be unique within a session; duplicate filenames are rejected and the file will not be analyzed. * Update python/packages/azure-ai-contentunderstanding/agent_framework_azure_ai_contentunderstanding/_context_provider.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/agent_framework_azure_ai_contentunderstanding/_context_provider.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/01-get-started/06_large_doc_file_search.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix AGENTS.md to match implementation; remove unused variable in test helper AGENTS.md: - Remove _ensure_initialized() reference (client is created in __init__) - Fix multi-segment docs: segments kept as list, not merged into fields - Remove get_analyzed_document() reference (only list_documents registered) - Update sample names to match current directory structure test_context_provider.py: - Simplify _make_data_uri() — remove unused 'encoded' variable * Fix premature file_search instruction for background-completed docs - Change _resolve_pending_tasks() instruction from 'Use file_search' to 'being indexed' since the upload hasn't completed yet at that point. - Add LLM instruction on upload failure in step 1b so the agent can inform the user the document isn't searchable. * fix: wrap long line in devui agent instructions (E501) * Fix Copilot review: unused logger, stray code in README, await cancelled tasks - _file_search.py: Remove unused logger and logging import - 01-multimodal_agent/README.md: Remove accidentally pasted Python script - _context_provider.py close(): Await cancelled tasks before closing client to prevent 'Task destroyed but pending' warnings * Sanitize doc keys and fix duplicate filename re-injection - Add _sanitize_doc_key() to strip control characters, collapse whitespace, and cap length at 255 chars — prevents prompt injection via crafted filenames in extend_instructions() calls. - Track accepted doc_keys in step 3 so step 5 only injects content for files actually analyzed this turn, not pre-existing duplicates. - Soften duplicate upload instruction wording (remove IMPORTANT/caps). * fix: add type annotation to tasks_to_cancel for pyright * Move per-session mutable state to state dict for session isolation Previously _pending_tasks, _pending_uploads, and _uploaded_file_ids were stored on self, shared across all sessions. This caused cross-session leakage: Session A's background task results could be injected into Session B's context. Now these are stored in the per-session state dict. Global copies (_all_pending_tasks, _all_uploaded_file_ids) are kept on self only for best-effort cleanup in close(). Add 2 new TestSessionIsolation tests verifying that background tasks and resolved content stay within their originating session. * Remove unused AnalysisSection enum values Only MARKDOWN and FIELDS are handled by _extract_sections(). Remove FIELD_GROUNDING, TABLES, PARAGRAPHS, SECTIONS to avoid exposing dead options to users. * Recursively flatten object/array field values for cleaner LLM output - Use SDK .value property with recursive extraction for object/array fields - Object: AmountDue -> {Amount: 610, CurrencyCode: USD} (was raw SDK dict) - Array: LineItems -> list of flattened items (was raw SDK list) - Update invoice fixture with object/array fields from prebuilt-invoice - Add 3 unit tests for object, array, and nested object field extraction * Preserve sub-field confidence; compare full expected JSON in tests * Remove incorrect MIME aliases (audio/mp4, video/x-matroska) * feat: add AnalysisInput, content_range, warnings, and category support - Use SDK AnalysisInput model instead of raw body dict for begin_analyze - Forward content_range from additional_properties to CU (page/time ranges) - Extract CU warnings with code/message/target (ODataV4Format) into output - Include content-level category from classifier analyzers - Add 5 new tests: warnings, category, content_range forwarding - Fix pyright with explicit casts; fix en-dash lint (RUF002) * fix: falsy-0 bug in duration calc; improve test coverage - Fix start_time_ms=0 treated as falsy by 'or' short-circuit, use 'is None' checks instead for duration and segment time extraction - Update warnings test to use RAI ContentFiltered codes - Enrich warnings extraction to include code/message/target (ODataV4Format) - Add multi-segment video category test with per-segment assertions * refactor: split _context_provider.py into focused modules - Extract _constants.py: SUPPORTED_MEDIA_TYPES, MIME_ALIASES, analyzer maps - Extract _detection.py: file detection, MIME sniffing, doc key derivation - Extract _extraction.py: result extraction, field flattening, LLM formatting - _context_provider.py delegates via thin wrappers (793 lines, was 1255) - Update test imports to use _constants.py for SUPPORTED_MEDIA_TYPES * docs: update AGENTS.md with DocumentStatus, FileSearchBackend, and _file_search.py * refactor: replace AnalysisSection enum with Literal type for simpler DX - Remove AnalysisSection(str, Enum) class, replace with Literal["markdown", "fields"] type alias - Users can now pass plain strings: output_sections=["markdown"] — no extra import needed - AnalysisSection type alias still exported for type annotation use - Update all samples, tests, and internal code to use string literals - Address PR review feedback (eavanvalkenburg) * refactor: replace asyncio.Task with continuation tokens for serializable state - Replace state["_pending_tasks"] (asyncio.Task — not serializable) with state["_pending_tokens"] (dict of continuation token strings) so the framework can persist session state to disk/storage - Resume pending analyses via Azure SDK continuation_token mechanism - Fix: resumed pollers have stale cached status (done() always False), use asyncio.wait_for(poller.result()) with 10s min timeout instead - Remove _background_poll(), _all_pending_tasks, and task cancellation - Address PR review feedback (eavanvalkenburg): state must be serializable * fix: resolve CI lint (RUF052) and mypy (call-overload) errors * feat: add structured output (Pydantic model) to invoice processing sample - Use response_format=InvoiceResult for schema-constrained LLM output - Use output_sections=["fields"] only (no markdown needed for structured output) - Add LowConfidenceField model with confidence values - Add comments about prebuilt-invoice extensive schema vs simplified model - Address PR review feedback (eavanvalkenburg): use structured response * fix: use FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL env vars in all samples Replace AZURE_AI_PROJECT_ENDPOINT → FOUNDRY_PROJECT_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME → FOUNDRY_MODEL across all sample .py and README.md files. Address PR review feedback (eavanvalkenburg). * refactor: remove background_analysis sample, use FoundryChatClient in DevUI - Remove 05_background_analysis.py (per reviewer feedback — discuss max_wait design separately from samples) - Renumber 06_large_doc_file_search.py → 05_large_doc_file_search.py - Replace AzureOpenAIResponsesClient with FoundryChatClient in all DevUI samples - Replace client.as_agent() with Agent(client=client, ...) everywhere - Add max_wait comments explaining interactive vs batch usage - Update README.md and AGENTS.md - Address PR review feedback (eavanvalkenburg) * fix: vector_stores API moved from beta namespace in OpenAI SDK * docs: add comments about multi-file support and CU service limits in file_search sample * fix: broken markdown links after sample removal and renumbering * fix: migrate BaseContextProvider to ContextProvider (non-deprecated) * fix: Message(text=) -> Message(contents=[]) for API compatibility * Inline _constants.py into consuming modules Remove _constants.py and move constants to where they are used: - SUPPORTED_MEDIA_TYPES, MIME_ALIASES → _detection.py - MEDIA_TYPE_ANALYZER_MAP, DEFAULT_ANALYZER → _context_provider.py Addresses review feedback to reduce file count. * Mark package as alpha per package management skill - Version: 1.0.0b260401 → 1.0.0a260401 - Classifier: Development Status 4 - Beta → 3 - Alpha - Add to PACKAGE_STATUS.md as alpha Follows the alpha package checklist from python-package-management skill. * Replace extend_instructions with extend_messages for status notifications Status/error/result notifications now use extend_messages (conversation context) instead of extend_instructions (system prompt). This avoids system prompt bloat and keeps behavioral directives separate from event notifications. - 11 extend_instructions calls → extend_messages (role='user') - 1 extend_instructions retained: tool usage guidelines (behavioral) - 6 test assertions updated to check context_messages All 84 unit tests + 5 live integration tests pass. * Fix lint: E402 import order, ISC004 implicit string concatenation - Move constants after all imports to fix E402 - Wrap multi-line strings in parentheses inside contents=[] to fix ISC004 * Fix lint: remove unused json import in invoice sample * Fix CI: apply ruff format + fix E501 line length after reformatting ruff format expands Message() calls to multi-line, pushing string indentation deeper. Break long strings to fit within 120 char limit after formatting. Also removes unused json import in sample. * Address review feedback: keyword-only args, accept pre-built client, remove wrappers - All __init__ args now keyword-only (matches FoundryChatClient pattern) - New 'client' param accepts pre-built ContentUnderstandingClient - core dep bound: >=1.0.0rc5 → >=1.0.0,<2 - Self import moved after local imports - Removed 9 static method wrappers; callsites use module functions directly - Tests updated to import derive_doc_key and format_result directly * fix: remove duplicate ContentUnderstandingClient instantiation The client was being created twice — once inside the if/else block and again unconditionally after it. The second instantiation overwrote the pre-built client path and failed type checking when credential was None. * rename: azure-ai-contentunderstanding → azure-contentunderstanding Package: agent-framework-azure-ai-contentunderstanding → agent-framework-azure-contentunderstanding Module: agent_framework_azure_ai_contentunderstanding → agent_framework_azure_contentunderstanding Directory: packages/azure-ai-contentunderstanding → packages/azure-contentunderstanding Per agreement with PM and MAF team to drop 'AI' from the package name. * feat: add ContentUnderstanding re-export to agent_framework.foundry namespace Enables: from agent_framework.foundry import ContentUnderstandingContextProvider Exports: ContentUnderstandingContextProvider, FileSearchConfig, FileSearchBackend, AnalysisSection, DocumentStatus Updates all samples and README to use the foundry namespace import. * fix: add missing copyright headers to standalone sample scripts * chore: remove .vscode/settings.json and add to .gitignore * refactor: reuse FoundryChatClient.client for vector store ops in file_search sample Address review feedback from TaoChenOSU: - 05_large_doc_file_search.py: use client.client instead of manually constructing AsyncAzureOpenAI; remove openai dependency - azure_openai_backend/agent.py: import reorder only (AIProjectClient kept — required for sync vector store creation in DevUI) * fix: skip closing client when caller passes pre-built client When a ContentUnderstandingClient is passed via client=, the caller owns its lifecycle. Added _owns_client flag so close() only closes the client when we created it internally. --------- Co-authored-by: yungshinlin Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/AGENTS.md | 1 + python/PACKAGE_STATUS.md | 1 + .../azure-contentunderstanding/.gitignore | 3 + .../azure-contentunderstanding/AGENTS.md | 71 + .../azure-contentunderstanding/LICENSE | 21 + .../azure-contentunderstanding/README.md | 127 + .../__init__.py | 28 + .../_context_provider.py | 858 +++++++ .../_detection.py | 234 ++ .../_extraction.py | 297 +++ .../_file_search.py | 101 + .../_models.py | 115 + .../azure-contentunderstanding/pyproject.toml | 100 + .../samples/01-get-started/01_document_qa.py | 119 + .../01-get-started/02_multi_turn_session.py | 145 ++ .../01-get-started/03_multimodal_chat.py | 188 ++ .../01-get-started/04_invoice_processing.py | 195 ++ .../05_large_doc_file_search.py | 166 ++ .../02-devui/01-multimodal_agent/README.md | 33 + .../02-devui/01-multimodal_agent/__init__.py | 6 + .../02-devui/01-multimodal_agent/agent.py | 68 + .../azure_openai_backend/README.md | 51 + .../azure_openai_backend/__init__.py | 6 + .../azure_openai_backend/agent.py | 105 + .../foundry_backend/README.md | 34 + .../foundry_backend/__init__.py | 1 + .../foundry_backend/agent.py | 110 + .../samples/README.md | 39 + .../samples/shared/sample_assets/invoice.pdf | Bin 0 -> 151363 bytes .../tests/cu/conftest.py | 106 + .../cu/fixtures/analyze_audio_result.json | 13 + .../cu/fixtures/analyze_image_result.json | 857 +++++++ .../cu/fixtures/analyze_invoice_result.json | 114 + .../tests/cu/fixtures/analyze_pdf_result.json | 23 + .../cu/fixtures/analyze_video_result.json | 51 + .../tests/cu/test_context_provider.py | 2049 +++++++++++++++++ .../tests/cu/test_integration.py | 312 +++ .../tests/cu/test_models.py | 67 + .../core/agent_framework/foundry/__init__.py | 6 + .../core/agent_framework/foundry/__init__.pyi | 12 + python/pyproject.toml | 2 + python/uv.lock | 43 + 42 files changed, 6878 insertions(+) create mode 100644 python/packages/azure-contentunderstanding/.gitignore create mode 100644 python/packages/azure-contentunderstanding/AGENTS.md create mode 100644 python/packages/azure-contentunderstanding/LICENSE create mode 100644 python/packages/azure-contentunderstanding/README.md create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py create mode 100644 python/packages/azure-contentunderstanding/pyproject.toml create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf create mode 100644 python/packages/azure-contentunderstanding/tests/cu/conftest.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_integration.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_models.py diff --git a/python/AGENTS.md b/python/AGENTS.md index e4697e18d5..f910919f95 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -69,6 +69,7 @@ python/ ### Azure Integrations - [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations +- [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider - [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG - [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider - [azurefunctions](packages/azurefunctions/AGENTS.md) - Azure Functions hosting diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 661cebe53a..7d9cc65767 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -18,6 +18,7 @@ Status is grouped into these buckets: | `agent-framework-a2a` | `python/packages/a2a` | `beta` | | `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` | | `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | +| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` | | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | | `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` | | `agent-framework-azurefunctions` | `python/packages/azurefunctions` | `beta` | diff --git a/python/packages/azure-contentunderstanding/.gitignore b/python/packages/azure-contentunderstanding/.gitignore new file mode 100644 index 0000000000..051cb93f3d --- /dev/null +++ b/python/packages/azure-contentunderstanding/.gitignore @@ -0,0 +1,3 @@ +# Local-only files (not committed) +_local_only/ +*_local_only* diff --git a/python/packages/azure-contentunderstanding/AGENTS.md b/python/packages/azure-contentunderstanding/AGENTS.md new file mode 100644 index 0000000000..530a01643d --- /dev/null +++ b/python/packages/azure-contentunderstanding/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md — azure-contentunderstanding + +## Package Overview + +`agent-framework-azure-contentunderstanding` integrates Azure Content Understanding (CU) +into the Agent Framework as a context provider. It automatically analyzes file attachments +(documents, images, audio, video) and injects structured results into the LLM context. + +## Public API + +| Symbol | Type | Description | +|--------|------|-------------| +| `ContentUnderstandingContextProvider` | class | Main context provider — extends `ContextProvider` | +| `AnalysisSection` | enum | Output section selector (MARKDOWN, FIELDS, etc.) | +| `DocumentStatus` | enum | Document lifecycle state (ANALYZING, UPLOADING, READY, FAILED) | +| `FileSearchBackend` | ABC | Abstract vector store file operations interface | +| `FileSearchConfig` | dataclass | Configuration for CU + vector store RAG mode | + +## Architecture + +- **`_context_provider.py`** — Main provider implementation. Overrides `before_run()` to detect + file attachments, call the CU API, manage session state with multi-document tracking, + and auto-register retrieval tools for follow-up turns. + - **Analyzer auto-detection** — When `analyzer_id=None` (default), `_resolve_analyzer_id()` + selects the CU analyzer based on media type prefix: `audio/` → `prebuilt-audioSearch`, + `video/` → `prebuilt-videoSearch`, everything else → `prebuilt-documentSearch`. + - **Multi-segment output** — CU splits long video/audio into multiple scene segments + (each a separate `contents[]` entry with its own `startTimeMs`, `endTimeMs`, `markdown`, + and `fields`). `_extract_sections()` produces: + - `segments`: list of per-segment dicts, each with `markdown`, `fields`, `start_time_s`, `end_time_s` + - `markdown`: concatenated at top level with `---` separators (for file_search uploads) + - `duration_seconds`: computed from global `min(startTimeMs)` → `max(endTimeMs)` + - Metadata (`kind`, `resolution`): taken from the first segment + - **Speaker diarization (not identification)** — CU transcripts label speakers as + ``, ``, etc. CU does **not** identify speakers by name. + - **file_search RAG** — When `FileSearchConfig` is provided, CU-extracted markdown is + uploaded to an OpenAI vector store and a `file_search` tool is registered on the context + instead of injecting the full document content. This enables token-efficient retrieval + for large documents. +- **`_models.py`** — `AnalysisSection` enum, `DocumentStatus` enum, `DocumentEntry` TypedDict, + `FileSearchConfig` dataclass. +- **`_file_search.py`** — `FileSearchBackend` ABC, `OpenAIFileSearchBackend`, + `FoundryFileSearchBackend`. + +## Key Patterns + +- Follows the Azure AI Search context provider pattern (same lifecycle, config style). +- Uses provider-scoped `state` dict for multi-document tracking across turns. +- Auto-registers `list_documents()` tool via `context.extend_tools()`. +- Configurable timeout (`max_wait`) with `asyncio.create_task()` background fallback. +- Strips supported binary attachments from `input_messages` to prevent LLM API errors. +- Explicit `analyzer_id` always overrides auto-detection (user preference wins). +- Vector store resources are cleaned up in `close()` / `__aexit__`. + +## Samples + +| Sample | Description | +|--------|-------------| +| `01_document_qa.py` | Upload a PDF via URL, ask questions about it | +| `02_multi_turn_session.py` | AgentSession persistence across turns | +| `03_multimodal_chat.py` | PDF + audio + video parallel analysis | +| `04_invoice_processing.py` | Structured field extraction with `prebuilt-invoice` analyzer | +| `05_large_doc_file_search.py` | CU extraction + OpenAI vector store RAG | +| `02-devui/01-multimodal_agent/` | DevUI web UI for CU-powered chat | +| `02-devui/02-file_search_agent/` | DevUI web UI combining CU + file_search RAG | + +## Running Tests + +```bash +uv run poe test -P azure-contentunderstanding +``` diff --git a/python/packages/azure-contentunderstanding/LICENSE b/python/packages/azure-contentunderstanding/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/azure-contentunderstanding/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/azure-contentunderstanding/README.md b/python/packages/azure-contentunderstanding/README.md new file mode 100644 index 0000000000..b46975fee3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/README.md @@ -0,0 +1,127 @@ +# Get Started with Azure Content Understanding in Microsoft Agent Framework + +Please install this package via pip: + +```bash +pip install agent-framework-azure-contentunderstanding --pre +``` + +## Azure Content Understanding Integration + +### Prerequisites + +Before using this package, you need an Azure Content Understanding resource: + +1. An active **Azure subscription** ([create one for free](https://azure.microsoft.com/pricing/purchase-options/azure-account)) +2. A **Microsoft Foundry resource** created in a [supported region](https://learn.microsoft.com/azure/ai-services/content-understanding/language-region-support) +3. **Default model deployments** configured for your resource (GPT-4.1, GPT-4.1-mini, text-embedding-3-large) + +Follow the [prerequisites section](https://learn.microsoft.com/azure/ai-services/content-understanding/quickstart/use-rest-api?tabs=portal%2Cdocument&pivots=programming-language-rest#prerequisites) in the Azure Content Understanding quickstart for setup instructions. + +### Introduction + +The Azure Content Understanding integration provides a context provider that automatically analyzes file attachments (documents, images, audio, video) using [Azure Content Understanding](https://learn.microsoft.com/azure/ai-services/content-understanding/) and injects structured results into the LLM context. + +- **Document & image analysis**: State-of-the-art OCR with markdown extraction, table preservation, and structured field extraction — handles scanned PDFs, handwritten content, and complex layouts +- **Audio & video analysis**: Transcription, speaker diarization, and per-segment summaries +- **Background processing**: Configurable timeout with async background fallback for large files +- **file_search integration**: Optional vector store upload for token-efficient RAG on large documents + +> Learn more about Azure Content Understanding capabilities at [https://learn.microsoft.com/azure/ai-services/content-understanding/](https://learn.microsoft.com/azure/ai-services/content-understanding/) + +### Basic Usage Example + +See the [samples directory](samples/) which demonstrates: + +- Single PDF upload and Q&A ([01_document_qa](samples/01-get-started/01_document_qa.py)) +- Multi-turn sessions with cached results ([02_multi_turn_session](samples/01-get-started/02_multi_turn_session.py)) +- PDF + audio + video parallel analysis ([03_multimodal_chat](samples/01-get-started/03_multimodal_chat.py)) +- Structured field extraction with prebuilt-invoice ([04_invoice_processing](samples/01-get-started/04_invoice_processing.py)) +- CU extraction + OpenAI vector store RAG ([05_large_doc_file_search](samples/01-get-started/05_large_doc_file_search.py)) +- Interactive web UI with DevUI ([02-devui](samples/02-devui/)) + +```python +import asyncio +from agent_framework import Agent, AgentSession, Message, Content +from agent_framework.foundry import FoundryChatClient +from agent_framework.foundry import ContentUnderstandingContextProvider +from azure.identity import AzureCliCredential + +credential = AzureCliCredential() + +cu = ContentUnderstandingContextProvider( + endpoint="https://my-resource.cognitiveservices.azure.com/", + credential=credential, + max_wait=None, # block until CU extraction completes before sending to LLM +) + +client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4.1", + credential=credential, +) + +async def main(): + async with cu: + agent = Agent( + client=client, + name="DocumentQA", + instructions="You are a helpful document analyst.", + context_providers=[cu], + ) + session = AgentSession() + + response = await agent.run( + Message(role="user", contents=[ + Content.from_text("What's on this invoice?"), + Content.from_uri( + "https://raw.githubusercontent.com/Azure-Samples/" + "azure-ai-content-understanding-assets/main/document/invoice.pdf", + media_type="application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + ]), + session=session, + ) + print(response.text) + +asyncio.run(main()) +``` + +### Supported File Types + +| Category | Types | +|----------|-------| +| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | +| Images | JPEG, PNG, TIFF, BMP | +| Audio | WAV, MP3, M4A, FLAC, OGG | +| Video | MP4, MOV, AVI, WebM | + +For the complete list of supported file types and size limits, see [Azure Content Understanding service limits](https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits). + +### Environment Variables + +The provider supports automatic endpoint resolution from environment variables. +When ``endpoint`` is not passed to the constructor, it is loaded from +``AZURE_CONTENTUNDERSTANDING_ENDPOINT``: + +```python +# Endpoint auto-loaded from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var +cu = ContentUnderstandingContextProvider(credential=credential) +``` + +Set these in your shell or in a `.env` file: + +```bash +AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/ +AZURE_AI_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1 +``` + +You also need to be logged in with `az login` (for `AzureCliCredential`). + +### Next steps + +- Explore the [samples directory](samples/) for complete code examples +- Read the [Azure Content Understanding documentation](https://learn.microsoft.com/azure/ai-services/content-understanding/) for detailed service information +- Learn more about the [Microsoft Agent Framework](https://aka.ms/agent-framework) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py new file mode 100644 index 0000000000..9b05519560 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Content Understanding integration for Microsoft Agent Framework. + +Provides a context provider that analyzes file attachments (documents, images, +audio, video) using Azure Content Understanding and injects structured results +into the LLM context. +""" + +import importlib.metadata + +from ._context_provider import ContentUnderstandingContextProvider +from ._file_search import FileSearchBackend +from ._models import AnalysisSection, DocumentStatus, FileSearchConfig + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "AnalysisSection", + "ContentUnderstandingContextProvider", + "DocumentStatus", + "FileSearchBackend", + "FileSearchConfig", + "__version__", +] diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py new file mode 100644 index 0000000000..3271d2a3ac --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py @@ -0,0 +1,858 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Content Understanding context provider using ContextProvider. + +This module provides ``ContentUnderstandingContextProvider``, built on the +:class:`ContextProvider` hooks pattern. It automatically detects file +attachments, analyzes them via the Azure Content Understanding API, and +injects structured results into the LLM context. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +import time +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + Content, + ContextProvider, + FunctionTool, + Message, + SessionContext, +) +from agent_framework._sessions import AgentSession +from agent_framework._settings import load_settings +from azure.ai.contentunderstanding.aio import ContentUnderstandingClient +from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential + +if TYPE_CHECKING: + from agent_framework._agents import SupportsAgentRun + +from ._detection import ( + detect_and_strip_files, +) +from ._extraction import extract_sections, format_result +from ._models import AnalysisSection, DocumentEntry, DocumentStatus, FileSearchConfig + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + +logger = logging.getLogger("agent_framework.azure_contentunderstanding") + +AzureCredentialTypes = AzureKeyCredential | AsyncTokenCredential + +# Mapping from media type prefix to the appropriate prebuilt CU analyzer. +# Used when analyzer_id is None (auto-detect mode). +MEDIA_TYPE_ANALYZER_MAP: dict[str, str] = { + "audio/": "prebuilt-audioSearch", + "video/": "prebuilt-videoSearch", +} +DEFAULT_ANALYZER: str = "prebuilt-documentSearch" + + +class ContentUnderstandingSettings(TypedDict, total=False): + """Settings for ContentUnderstandingContextProvider with auto-loading from environment. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + ``AZURE_CONTENTUNDERSTANDING_``. + + Keys: + endpoint: Azure AI Foundry endpoint URL. + Can be set via environment variable ``AZURE_CONTENTUNDERSTANDING_ENDPOINT``. + """ + + endpoint: str | None + + +class ContentUnderstandingContextProvider(ContextProvider): + """Context provider that analyzes file attachments using Azure Content Understanding. + + Automatically detects supported file attachments in the agent's input, + analyzes them via CU, and injects the structured results (markdown, fields) + into the LLM context. Supports multiple documents per session with background + processing for long-running analyses. Optionally integrates with a vector + store backend for ``file_search``-based RAG retrieval on LLM clients that + support it. + + Args: + endpoint: Azure AI Foundry endpoint URL + (e.g., ``"https://.services.ai.azure.com/"``). + Can also be set via environment variable + ``AZURE_CONTENTUNDERSTANDING_ENDPOINT``. + credential: An ``AzureKeyCredential`` for API key auth or an + ``AsyncTokenCredential`` (e.g., ``DefaultAzureCredential``) for + Microsoft Entra ID auth. + analyzer_id: A prebuilt or custom CU analyzer ID. When ``None`` + (default), a prebuilt analyzer is chosen automatically based on + the file's media type: ``prebuilt-documentSearch`` for documents + and images, ``prebuilt-audioSearch`` for audio, and + ``prebuilt-videoSearch`` for video. + Analyzer reference: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/analyzer-reference + Prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers + max_wait: Max seconds to wait for analysis before deferring to background. + ``None`` waits until complete. + output_sections: Which CU output sections to pass to LLM. + Defaults to ``["markdown", "fields"]``. + file_search: Optional configuration for uploading CU-extracted markdown to + a vector store for token-efficient RAG retrieval. When provided, full + content injection is replaced by ``file_search`` tool registration. + The ``FileSearchConfig`` abstraction is backend-agnostic — use + ``FileSearchConfig.from_openai()`` or ``FileSearchConfig.from_foundry()`` + for supported providers, or supply a custom ``FileSearchBackend`` + implementation for other vector store services. + source_id: Unique identifier for this provider instance, used for message + attribution and tool registration. Defaults to ``"azure_contentunderstanding"``. + env_file_path: Path to a ``.env`` file for loading settings. + env_file_encoding: Encoding of the ``.env`` file. + + Per-file ``additional_properties`` on ``Content`` objects: + The provider reads the following keys from + ``Content.additional_properties`` (passed via ``Content.from_data()`` + or ``Content.from_uri()``): + + ``filename`` (str): + The document key used for tracking, status, and LLM references. + Without a filename, a UUID-based key is generated. + Must be unique within a session — uploading a file with a + duplicate filename will be rejected and the file will not be + analyzed. + + ``analyzer_id`` (str): + Per-file analyzer override. Takes priority over the provider-level + ``analyzer_id``. Useful for mixing analyzers in the same turn + (e.g., ``prebuilt-invoice`` for invoices alongside + ``prebuilt-documentSearch`` for general documents). + + ``content_range`` (str): + Subset of the input to analyze. For documents, use 1-based page + numbers (e.g., ``"1-3"`` for pages 1-3, ``"1,3,5-"`` for pages + 1, 3, and 5 onward). For audio/video, use milliseconds + (e.g., ``"0-60000"`` for the first 60 seconds). + + Example:: + + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={ + "filename": "invoice.pdf", + "analyzer_id": "prebuilt-invoice", + "content_range": "1-3", + }, + ) + """ + + DEFAULT_SOURCE_ID: ClassVar[str] = "azure_contentunderstanding" + DEFAULT_MAX_WAIT_SECONDS: ClassVar[float] = 5.0 + + def __init__( + self, + *, + endpoint: str | None = None, + credential: AzureCredentialTypes | None = None, + client: ContentUnderstandingClient | None = None, + analyzer_id: str | None = None, + max_wait: float | None = DEFAULT_MAX_WAIT_SECONDS, + output_sections: list[AnalysisSection] | None = None, + file_search: FileSearchConfig | None = None, + source_id: str = DEFAULT_SOURCE_ID, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + super().__init__(source_id) + + if client is not None: + # Use the pre-built client directly — endpoint/credential are ignored. + self._client = client + self._owns_client = False + self._endpoint = "" + self._credential = None + else: + # Build a new client from endpoint + credential. + settings = load_settings( + ContentUnderstandingSettings, + env_prefix="AZURE_CONTENTUNDERSTANDING_", + required_fields=["endpoint"], + endpoint=endpoint, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated by load_settings + + if credential is None: + raise ValueError( + "Azure credential is required. Provide a 'credential' keyword argument " + "(e.g., AzureKeyCredential or AzureCliCredential), or pass a pre-built " + "'client' (ContentUnderstandingClient) instead." + ) + + self._endpoint = resolved_endpoint + self._credential = credential + self._client = ContentUnderstandingClient( + self._endpoint, self._credential, user_agent=AGENT_FRAMEWORK_USER_AGENT + ) + self._owns_client = True + self.analyzer_id = analyzer_id + self.max_wait = max_wait + self.output_sections: list[AnalysisSection] = output_sections or ["markdown", "fields"] + self.file_search = file_search + # Global list of uploaded file IDs — used only by close() for + # best-effort cleanup. The authoritative per-session copy lives in + # state["_uploaded_file_ids"] (populated in before_run). This global + # list may contain entries from multiple sessions; that is intentional + # for cleanup. + self._all_uploaded_file_ids: list[str] = [] + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit — cleanup clients.""" + await self.close() + + async def close(self) -> None: + """Close the underlying CU client and clean up resources. + + Uses global tracking lists for best-effort cleanup across all + sessions that used this provider instance. + """ + # Clean up uploaded files; the vector store itself is caller-managed. + if self.file_search and self._all_uploaded_file_ids: + await self._cleanup_uploaded_files() + # Only close the client if we created it internally. + # When a pre-built client was passed in, the caller owns its lifecycle. + if self._owns_client: + await self._client.close() + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Analyze file attachments and inject results into the LLM context. + + This method is called automatically by the framework before each LLM invocation. + """ + documents: dict[str, DocumentEntry] = state.setdefault("documents", {}) + + # Per-session mutable state — isolated per session to prevent cross-session leakage. + # _pending_tokens stores serializable continuation tokens (not asyncio.Task objects) + # so that state can be persisted to disk/storage by the framework. + # Structure: {doc_key: {"continuation_token": , + # "analyzer_id": }} + pending_tokens: dict[str, dict[str, str]] = state.setdefault("_pending_tokens", {}) + pending_uploads: list[tuple[str, DocumentEntry]] = state.setdefault("_pending_uploads", []) + + # 1. Resolve pending background analyses via continuation tokens + await self._resolve_pending_tokens(pending_tokens, pending_uploads, documents, context) + + # 1b. Upload any documents that completed in the background (file_search mode) + if pending_uploads: + # Use a bounded timeout so before_run() stays responsive and does not block + # indefinitely on slow vector store indexing. + upload_timeout = getattr(self, "max_wait", None) + remaining_uploads: list[tuple[str, DocumentEntry]] = [] + for upload_key, upload_entry in pending_uploads: + try: + if upload_timeout is not None: + await asyncio.wait_for( + self._upload_to_vector_store(upload_key, upload_entry, state=state), + timeout=upload_timeout, + ) + else: + await self._upload_to_vector_store(upload_key, upload_entry, state=state) + except asyncio.TimeoutError: + # Leave timed-out uploads pending so they can be retried on a later turn. + logger.warning( + "Timed out while uploading document '%s' to vector store; will retry later.", + upload_key, + ) + remaining_uploads.append((upload_key, upload_entry)) + except Exception: + # Log unexpected failures and drop the upload entry; this matches prior + # behavior where all pending uploads were cleared regardless of outcome. + logger.exception( + "Error while uploading document '%s' to vector store; dropping from pending list.", + upload_key, + ) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"Document '{upload_key}' was analyzed but failed to upload " + "to the vector store. The document content is not available for search." + ) + ], + ) + ], + ) + state["_pending_uploads"] = remaining_uploads + pending_uploads = remaining_uploads + + # 2. Detect CU-supported file attachments, strip them from input, and return for analysis + new_files = detect_and_strip_files(context) + + # 3. Analyze new files using CU (track elapsed time for combined timeout) + file_start_times: dict[str, float] = {} + accepted_keys: set[str] = set() # doc_keys successfully accepted for analysis this turn + for doc_key, content_item, binary_data in new_files: + # Reject duplicate filenames — re-analyzing would orphan vector store entries + if doc_key in documents: + logger.warning("Duplicate document key '%s' — skipping (already exists in session).", doc_key) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"The user tried to upload '{doc_key}', but a file with that name " + "was already uploaded earlier in this session. The new upload was rejected " + "and was not analyzed. Tell the user that a file with the same name " + "already exists and they need to rename the file before uploading again." + ) + ], + ) + ], + ) + continue + file_start_times[doc_key] = time.monotonic() + doc_entry = await self._analyze_file(doc_key, content_item, binary_data, context, pending_tokens) + if doc_entry: + documents[doc_key] = doc_entry + accepted_keys.add(doc_key) + + # 4. Inject content for ready documents and register tools + if documents: + self._register_tools(documents, context) + + # 5. On upload turns, inject content for docs accepted this turn + for doc_key in accepted_keys: + entry = documents.get(doc_key) + if entry and entry["status"] == DocumentStatus.READY and entry["result"]: + # Upload to vector store if file_search is configured + if self.file_search: + # Combined timeout: subtract CU analysis time from max_wait + remaining: float | None = None + if self.max_wait is not None: + elapsed = time.monotonic() - file_start_times.get(doc_key, time.monotonic()) + remaining = max(0.0, self.max_wait - elapsed) + uploaded = await self._upload_to_vector_store(doc_key, entry, timeout=remaining, state=state) + if uploaded: + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"The user just uploaded '{entry['filename']}'. It has been analyzed " + "using Azure Content Understanding and indexed in a vector store. " + f"When using file_search, include '{entry['filename']}' in your query " + "to retrieve content from this specific document." + ) + ], + ) + ], + ) + elif entry.get("error"): + # Upload failed (not timeout — actual error) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"Document '{entry['filename']}' was analyzed but failed to upload " + "to the vector store. The document content is not available for search." + ) + ], + ) + ], + ) + else: + # Upload deferred to background (timeout) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"Document '{entry['filename']}' has been analyzed and is being indexed. " + "Ask about it again in a moment." + ) + ], + ) + ], + ) + else: + # Without file_search, inject full content into context + context.extend_messages( + self, + [ + Message(role="user", contents=[format_result(entry["filename"], entry["result"])]), + ], + ) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + ( + f"The user just uploaded '{entry['filename']}'." + " It has been analyzed using Azure Content Understanding." + " The document content (markdown) and extracted fields" + " (JSON) are provided above." + " If the user's question is ambiguous," + " prioritize this most recently uploaded document." + " Use specific field values and cite page numbers" + " when answering." + ) + ], + ) + ], + ) + + # 6. Register file_search tool (for LLM clients that support it) + if self.file_search: + context.extend_tools( + self.source_id, + [self.file_search.file_search_tool], + ) + context.extend_instructions( + self.source_id, + "Tool usage guidelines:\n" + "- Use file_search ONLY when answering questions about document content.\n" + "- Use list_documents() for status queries (e.g. 'list docs', 'what's uploaded?').\n" + "- Do NOT call file_search for status queries — it wastes tokens.", + ) + + # ------------------------------------------------------------------ + # Analyzer Resolution + # ------------------------------------------------------------------ + + def _resolve_analyzer_id(self, media_type: str) -> str: + """Return the analyzer ID to use for the given media type. + + When ``self.analyzer_id`` is set, it is always returned (explicit + override). Otherwise the media type prefix is matched against the + known mapping, falling back to ``prebuilt-documentSearch``. + """ + if self.analyzer_id is not None: + return self.analyzer_id + for prefix, analyzer in MEDIA_TYPE_ANALYZER_MAP.items(): + if media_type.startswith(prefix): + return analyzer + return DEFAULT_ANALYZER + + # ------------------------------------------------------------------ + # Analysis + # ------------------------------------------------------------------ + + async def _analyze_file( + self, + doc_key: str, + content: Content, + binary_data: bytes | None, + context: SessionContext, + pending_tokens: dict[str, dict[str, str]] | None = None, + ) -> DocumentEntry | None: + """Analyze a single file via CU with timeout handling. + + The analyzer is resolved in priority order: + 1. Per-file override via ``content.additional_properties["analyzer_id"]`` + 2. Provider-level default via ``self.analyzer_id`` + 3. Auto-detect by media type (document/audio/video) + + Returns: + A ``DocumentEntry`` (ready, analyzing, or failed), or ``None`` if + file data could not be extracted. + """ + media_type = content.media_type or "application/octet-stream" + filename = doc_key + + # Per-file analyzer override from additional_properties + props = content.additional_properties or {} + per_file_analyzer = props.get("analyzer_id") + content_range = props.get("content_range") + resolved_analyzer = per_file_analyzer or self._resolve_analyzer_id(media_type) + t0 = time.monotonic() + + try: + # Start CU analysis + if content.type == "uri" and content.uri and not content.uri.startswith("data:"): + poller = await self._client.begin_analyze( + resolved_analyzer, + inputs=[AnalysisInput(url=content.uri, content_range=content_range)], + ) + elif binary_data: + poller = await self._client.begin_analyze_binary( + resolved_analyzer, + binary_input=binary_data, + content_type=media_type, + ) + else: + context.extend_messages( + self.source_id, + [Message(role="user", contents=[f"Could not extract file data from '{filename}'."])], + ) + return None + + # Wait with timeout; defer to background polling on timeout. + try: + result = await asyncio.wait_for(poller.result(), timeout=self.max_wait) + except asyncio.TimeoutError: + # Save continuation token for resuming on next before_run(). + # Continuation tokens are serializable strings, so state can + # be persisted to disk/storage without issues. + token = poller.continuation_token() + logger.info("Analysis of '%s' timed out; deferring to background via continuation token.", filename) + if pending_tokens is not None: + pending_tokens[doc_key] = { + "continuation_token": token, + "analyzer_id": resolved_analyzer, + } + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[f"Document '{filename}' is being analyzed. Ask about it again in a moment."], + ) + ], + ) + return DocumentEntry( + status=DocumentStatus.ANALYZING, + filename=filename, + media_type=media_type, + analyzer_id=resolved_analyzer, + analyzed_at=None, + analysis_duration_s=None, + upload_duration_s=None, + result=None, + error=None, + ) + + # Analysis completed within timeout + analysis_duration = round(time.monotonic() - t0, 2) + extracted = self._extract_sections(result) + logger.info("Analyzed '%s' with analyzer '%s' in %.1fs.", filename, resolved_analyzer, analysis_duration) + return DocumentEntry( + status=DocumentStatus.READY, + filename=filename, + media_type=media_type, + analyzer_id=resolved_analyzer, + analyzed_at=datetime.now(tz=timezone.utc).isoformat(), + analysis_duration_s=analysis_duration, + upload_duration_s=None, + result=extracted, + error=None, + ) + + except asyncio.TimeoutError: + raise + except Exception as e: + logger.warning("CU analysis error for '%s': %s", filename, e) + context.extend_messages( + self.source_id, + [Message(role="user", contents=[f"Could not analyze '{filename}': {e}"])], + ) + return DocumentEntry( + status=DocumentStatus.FAILED, + filename=filename, + media_type=media_type, + analyzer_id=resolved_analyzer, + analyzed_at=datetime.now(tz=timezone.utc).isoformat(), + analysis_duration_s=round(time.monotonic() - t0, 2), + upload_duration_s=None, + result=None, + error=str(e), + ) + + # ------------------------------------------------------------------ + # Pending Token Resolution + # ------------------------------------------------------------------ + + async def _resolve_pending_tokens( + self, + pending_tokens: dict[str, dict[str, str]], + pending_uploads: list[tuple[str, DocumentEntry]], + documents: dict[str, DocumentEntry], + context: SessionContext, + ) -> None: + """Resume pending CU analyses using serializable continuation tokens. + + When a file's CU analysis exceeds ``max_wait``, a continuation token + (an opaque string from the Azure SDK) is saved in ``state`` instead of + an ``asyncio.Task``. This keeps state fully serializable — it can be + persisted to disk/storage by the framework. + + On the next ``before_run()`` call, this method resumes each pending + operation by passing the token back to ``begin_analyze()``. If the + server-side operation has completed, the result is available + immediately; otherwise the token is kept for the next turn. + """ + if not pending_tokens: + return + logger.info("Resolving %d pending analysis token(s).", len(pending_tokens)) + completed_keys: list[str] = [] + + for doc_key, token_info in pending_tokens.items(): + entry = documents.get(doc_key) + if not entry: + completed_keys.append(doc_key) + continue + + try: + poller = await self._client.begin_analyze( # type: ignore[call-overload, reportUnknownVariableType] + token_info["analyzer_id"], + continuation_token=token_info["continuation_token"], # pyright: ignore[reportCallIssue] + ) + # Use wait_for to avoid blocking before_run indefinitely. + # poller.done() always returns False for resumed pollers (stale + # cached status), so we call poller.result() which polls the server. + # + # Timeout: at least 10s regardless of max_wait. The upload-turn + # max_wait can be very short (e.g. 5s) for responsiveness, but + # on resolution turns the resumed poller needs a network round-trip + # to fetch the result. If the analysis is still running after 10s, + # the token is kept and retried on the next turn. + MIN_RESOLUTION_TIMEOUT = 10.0 + resolution_timeout = max(self.max_wait or MIN_RESOLUTION_TIMEOUT, MIN_RESOLUTION_TIMEOUT) + try: + result: AnalysisResult = await asyncio.wait_for( + poller.result(), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + timeout=resolution_timeout, + ) # pyright: ignore[reportUnknownVariableType] + except asyncio.TimeoutError: + # Still running — update token and keep for next turn + new_token: str = poller.continuation_token() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + token_info["continuation_token"] = new_token + logger.info("Analysis for '%s' still running; keeping token for next turn.", doc_key) + continue + + completed_keys.append(doc_key) + extracted = self._extract_sections(result) # pyright: ignore[reportUnknownArgumentType] + entry["status"] = DocumentStatus.READY + entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat() + entry["result"] = extracted + entry["error"] = None + logger.info("Background analysis of '%s' completed.", entry["filename"]) + + # Inject newly ready content + if self.file_search: + pending_uploads.append((doc_key, entry)) + else: + context.extend_messages( + self, + [ + Message(role="user", contents=[format_result(entry["filename"], extracted)]), + ], + ) + context.extend_messages( + self.source_id, + [ + Message( + role="user", + contents=[ + f"Document '{entry['filename']}' analysis is now complete." + + ( + " The document is being indexed in the vector store and will become" + " searchable via file_search shortly." + if self.file_search + else " The content is provided above." + ) + ], + ) + ], + ) + + except Exception as e: + completed_keys.append(doc_key) + logger.warning("Background analysis of '%s' failed: %s", entry.get("filename", doc_key), e) + entry["status"] = DocumentStatus.FAILED + entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat() + entry["error"] = str(e) + context.extend_messages( + self.source_id, + [Message(role="user", contents=[f"Document '{entry['filename']}' analysis failed: {e}"])], + ) + + for key in completed_keys: + del pending_tokens[key] + + # ------------------------------------------------------------------ + # Output Extraction & Formatting (delegates to _extraction module) + # ------------------------------------------------------------------ + + def _extract_sections(self, result: AnalysisResult) -> dict[str, object]: + return extract_sections(result, self.output_sections) + + # ------------------------------------------------------------------ + # Tool Registration + # ------------------------------------------------------------------ + + def _register_tools( + self, + documents: dict[str, DocumentEntry], + context: SessionContext, + ) -> None: + """Register document tools on the context. + + Only ``list_documents`` is registered — the full document content is + already injected into conversation history on the upload turn, so a + separate retrieval tool is not needed. + """ + context.extend_tools( + self.source_id, + [self._make_list_documents_tool(documents)], + ) + + @staticmethod + def _make_list_documents_tool(documents: dict[str, DocumentEntry]) -> FunctionTool: + """Create a tool that lists all tracked documents with their status.""" + docs_ref = documents + + def list_documents() -> str: + """List all documents that have been uploaded and their analysis status.""" + entries: list[dict[str, object]] = [] + for name, entry in docs_ref.items(): + entries.append({ + "name": name, + "status": entry["status"], + "media_type": entry["media_type"], + "analyzed_at": entry["analyzed_at"], + "analysis_duration_s": entry["analysis_duration_s"], + "upload_duration_s": entry["upload_duration_s"], + }) + return json.dumps(entries, indent=2, default=str) + + return FunctionTool( + name="list_documents", + description=( + "List all documents that have been uploaded in this session " + "with their analysis status (analyzing, uploading, ready, or failed)." + ), + func=list_documents, + ) + + # ------------------------------------------------------------------ + # file_search Vector Store Integration + # ------------------------------------------------------------------ + + async def _upload_to_vector_store( + self, + doc_key: str, + entry: DocumentEntry, + *, + timeout: float | None = None, + state: dict[str, Any] | None = None, + ) -> bool: + """Upload CU-extracted markdown to the caller's vector store. + + Delegates to the configured ``FileSearchBackend`` (OpenAI, Foundry, + or a custom implementation). The upload includes file upload **and** + vector store indexing (embedding + ingestion) — ``create_and_poll`` + waits for the index to be fully ready before returning. + + Args: + doc_key: Document identifier. + entry: The document entry with extracted results. + timeout: Max seconds to wait for upload + indexing. ``None`` waits + indefinitely. On timeout the upload is deferred to the + per-session ``_pending_uploads`` queue for the next + ``before_run()`` call. + state: Per-session state dict for tracking uploaded file IDs and + pending uploads. + + Returns: + True if the upload succeeded, False otherwise. + """ + if not self.file_search: + return False + + result = entry.get("result") + if not result: + return False + + # Upload the full formatted content (markdown + fields + segments), + # not just raw markdown — consistent with what non-file_search mode injects. + formatted = format_result(entry["filename"], result) + if not formatted: + return False + + entry["status"] = DocumentStatus.UPLOADING + t0 = time.monotonic() + + try: + upload_coro = self.file_search.backend.upload_file( + self.file_search.vector_store_id, f"{doc_key}.md", formatted.encode("utf-8") + ) + file_id = await asyncio.wait_for(upload_coro, timeout=timeout) + upload_duration = round(time.monotonic() - t0, 2) + # Track in per-session state and global list (for close() cleanup) + if state is not None: + state.setdefault("_uploaded_file_ids", []).append(file_id) + self._all_uploaded_file_ids.append(file_id) + entry["status"] = DocumentStatus.READY + entry["upload_duration_s"] = upload_duration + logger.info("Uploaded '%s' to vector store in %.1fs (%s bytes).", doc_key, upload_duration, len(formatted)) + return True + + except asyncio.TimeoutError: + logger.info("Vector store upload for '%s' timed out; deferring to background.", doc_key) + entry["status"] = DocumentStatus.UPLOADING + if state is not None: + state.setdefault("_pending_uploads", []).append((doc_key, entry)) + return False + + except Exception as e: + logger.warning("Failed to upload '%s' to vector store: %s", doc_key, e) + entry["status"] = DocumentStatus.FAILED + entry["upload_duration_s"] = round(time.monotonic() - t0, 2) + entry["error"] = f"Vector store upload failed: {e}" + return False + + async def _cleanup_uploaded_files(self) -> None: + """Delete files uploaded by this provider via the configured backend. + + The vector store itself is caller-managed and is not deleted here. + """ + if not self.file_search: + return + + backend = self.file_search.backend + + try: + for file_id in self._all_uploaded_file_ids: + await backend.delete_file(file_id) + self._all_uploaded_file_ids.clear() + + except Exception as e: + logger.warning("Failed to clean up uploaded files: %s", e) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py new file mode 100644 index 0000000000..75ee88d7d3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py @@ -0,0 +1,234 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""File detection utilities for Azure Content Understanding context provider. + +Functions for scanning input messages, sniffing MIME types, deriving +document keys, and extracting binary data from content items. +""" + +from __future__ import annotations + +import base64 +import logging +import mimetypes +import re +import uuid + +import filetype +from agent_framework import Content, SessionContext + +logger = logging.getLogger("agent_framework.azure_contentunderstanding") + +# MIME types used to match against the resolved media type for routing files to CU analysis. +# The media type may be provided via Content.media_type or inferred (e.g., via sniffing or filename) +# when missing or generic (such as application/octet-stream). Only files whose resolved media type is +# in this set will be processed; others are skipped. +# +# Supported input file types: +# https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits +SUPPORTED_MEDIA_TYPES: frozenset[str] = frozenset({ + # Documents and images + "application/pdf", + "image/jpeg", + "image/png", + "image/tiff", + "image/bmp", + "image/heif", + "image/heic", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + # Text + "text/plain", + "text/html", + "text/markdown", + "text/rtf", + "text/xml", + "application/xml", + "message/rfc822", + "application/vnd.ms-outlook", + # Audio + "audio/wav", + "audio/mpeg", + "audio/mp3", + "audio/mp4", + "audio/m4a", + "audio/flac", + "audio/ogg", + "audio/opus", + "audio/webm", + "audio/x-ms-wma", + "audio/aac", + "audio/amr", + "audio/3gpp", + # Video + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/x-flv", + "video/x-ms-wmv", + "video/x-ms-asf", + "video/x-matroska", +}) + +# Mapping from filetype's MIME output to our canonical SUPPORTED_MEDIA_TYPES values. +# filetype uses some x-prefixed variants that differ from our set. +MIME_ALIASES: dict[str, str] = { + "audio/x-wav": "audio/wav", + "audio/x-flac": "audio/flac", + "video/x-m4v": "video/mp4", +} + + +def detect_and_strip_files( + context: SessionContext, +) -> list[tuple[str, Content, bytes | None]]: + """Scan input messages for supported file content and prepare for CU analysis. + + Scans for type ``data`` or ``uri`` content supported by Azure Content + Understanding, strips them from messages to prevent raw binary being sent + to the LLM, and returns metadata for CU analysis. + + Detected files are tracked via ``doc_key`` (derived from filename, URL, + or UUID) and their analysis status is managed in session state. + + When the upstream MIME type is unreliable (``application/octet-stream`` + or missing), binary content sniffing via ``filetype`` is used to + determine the real media type, with ``mimetypes.guess_type`` as a + filename-based fallback. + + Returns: + List of (doc_key, content_item, binary_data) tuples for files to analyze. + """ + results: list[tuple[str, Content, bytes | None]] = [] + strip_ids: set[int] = set() + + for msg in context.input_messages: + for c in msg.contents: + if c.type not in ("data", "uri"): + continue + + media_type = c.media_type + # Fast path: already a known supported type + if media_type and media_type in SUPPORTED_MEDIA_TYPES: + binary_data = extract_binary(c) + results.append((derive_doc_key(c), c, binary_data)) + strip_ids.add(id(c)) + continue + + # Slow path: unreliable MIME — sniff binary content + if (not media_type) or (media_type == "application/octet-stream"): + binary_data = extract_binary(c) + resolved = sniff_media_type(binary_data, c) + if resolved and (resolved in SUPPORTED_MEDIA_TYPES): + c.media_type = resolved + results.append((derive_doc_key(c), c, binary_data)) + strip_ids.add(id(c)) + + # Strip detected files from input so raw binary isn't sent to LLM + msg.contents = [c for c in msg.contents if id(c) not in strip_ids] + + return results + + +def sniff_media_type(binary_data: bytes | None, content: Content) -> str | None: + """Sniff the actual MIME type from binary data, with filename fallback. + + Uses ``filetype`` (magic-bytes) first, then ``mimetypes.guess_type`` + on the filename. Normalizes filetype's variant MIME values (e.g. + ``audio/x-wav`` -> ``audio/wav``) via ``MIME_ALIASES``. + """ + # 1. Binary sniffing via filetype (needs only first 261 bytes) + if binary_data: + kind = filetype.guess(binary_data[:262]) # type: ignore[reportUnknownMemberType] + if kind: + mime: str = kind.mime # type: ignore[reportUnknownMemberType] + return MIME_ALIASES.get(mime, mime) + + # 2. Filename extension fallback — try additional_properties first, + # then extract basename from external URL path + filename: str | None = None + if content.additional_properties: + filename = content.additional_properties.get("filename") + if not filename and content.uri and not content.uri.startswith("data:"): + # Extract basename from URL path (e.g. "https://example.com/report.pdf?v=1" -> "report.pdf") + filename = content.uri.split("?")[0].split("#")[0].rsplit("/", 1)[-1] + if filename: + guessed, _ = mimetypes.guess_type(filename) # uses file extension to guess MIME type + if guessed: + return MIME_ALIASES.get(guessed, guessed) + + return None + + +def is_supported_content(content: Content) -> bool: + """Check if a content item is a supported file type for CU analysis.""" + if content.type not in ("data", "uri"): + return False + media_type = content.media_type + if not media_type: + return False + return media_type in SUPPORTED_MEDIA_TYPES + + +def sanitize_doc_key(raw: str) -> str: + """Sanitize a document key to prevent prompt injection. + + Removes control characters (newlines, tabs, etc.), collapses + whitespace, strips surrounding whitespace, and caps length at + 255 characters. + """ + # Remove control characters (C0/C1 controls, including \n, \r, \t) + cleaned = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", raw) + # Collapse whitespace + cleaned = " ".join(cleaned.split()) + # Cap length + return cleaned[:255] if cleaned else f"doc_{uuid.uuid4().hex[:8]}" + + +def derive_doc_key(content: Content) -> str: + """Derive a unique document key from content metadata. + + The key is used to track documents in session state. Duplicate keys + within a session are rejected (not re-analyzed) to prevent orphaned + vector store entries. + + The returned key is sanitized to prevent prompt injection via + crafted filenames (control characters removed, length capped). + + Priority: filename > URL basename > generated UUID. + """ + # 1. Filename from additional_properties + if content.additional_properties: + filename = content.additional_properties.get("filename") + if filename and isinstance(filename, str): + return sanitize_doc_key(filename) + + # 2. URL path basename for external URIs (e.g. "https://example.com/report.pdf" -> "report.pdf") + if content.type == "uri" and content.uri and not content.uri.startswith("data:"): + path = content.uri.split("?")[0].split("#")[0] # strip query params and fragments + # rstrip("/") handles trailing slashes (e.g. ".../files/" -> ".../files") + # rsplit("/", 1)[-1] splits from the right once to get the last path segment + basename = path.rstrip("/").rsplit("/", 1)[-1] + if basename: + return sanitize_doc_key(basename) + + # 3. Fallback: generate a unique ID for anonymous uploads (no filename, no URL) + return f"doc_{uuid.uuid4().hex[:8]}" + + +def extract_binary(content: Content) -> bytes | None: + """Extract binary data from a data URI content item. + + Only handles ``data:`` URIs (base64-encoded). Returns ``None`` for + external URLs -- those are passed directly to CU via ``begin_analyze``. + """ + if content.uri and content.uri.startswith("data:"): + try: + _, data_part = content.uri.split(",", 1) + return base64.b64decode(data_part) + except Exception: + logger.warning("Failed to decode base64 data URI") + return None + return None diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py new file mode 100644 index 0000000000..adef84fb89 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py @@ -0,0 +1,297 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Output extraction and formatting for Azure Content Understanding results. + +Converts CU ``AnalysisResult`` objects into plain Python dicts suitable +for LLM consumption, and formats them as human-readable text. +""" + +from __future__ import annotations + +import json +from typing import Any, cast + +from azure.ai.contentunderstanding.models import AnalysisResult + +from ._models import AnalysisSection + + +def extract_sections( + result: AnalysisResult, + output_sections: list[AnalysisSection], +) -> dict[str, object]: + """Extract configured sections from a CU analysis result. + + For single-segment results (documents, images, short audio), returns a flat + dict with ``markdown`` and ``fields`` at the top level. + + For multi-segment results (e.g. video split into scenes), fields are kept + with their respective segments in a ``segments`` list so the LLM can see + which fields belong to which part of the content: + - ``segments``: list of per-segment dicts with ``markdown``, ``fields``, + ``start_time_s``, and ``end_time_s`` + - ``markdown``: still concatenated at top level for file_search uploads + - ``duration_seconds``: computed from the global time span + - ``kind`` / ``resolution``: taken from the first segment + """ + extracted: dict[str, object] = {} + contents = result.contents + if not contents: + return extracted + + # --- Warnings from the CU service (ODataV4Format with code/message/target) --- + if result.warnings: + warnings_out: list[dict[str, str]] = [] + for w in result.warnings: + entry: dict[str, str] = {} + code = getattr(w, "code", None) + if code: + entry["code"] = code + msg = getattr(w, "message", None) + entry["message"] = msg if msg else str(w) + target = getattr(w, "target", None) + if target: + entry["target"] = target + warnings_out.append(entry) + extracted["warnings"] = warnings_out + + # --- Media metadata (from first segment) --- + first = contents[0] + kind = getattr(first, "kind", None) + if kind: + extracted["kind"] = kind + width = getattr(first, "width", None) + height = getattr(first, "height", None) + if width and height: + extracted["resolution"] = f"{width}x{height}" + + # Compute total duration from the global time span of all segments. + global_start: int | None = None + global_end: int | None = None + for content in contents: + s = getattr(content, "start_time_ms", None) + if s is None: + s = getattr(content, "startTimeMs", None) + e = getattr(content, "end_time_ms", None) + if e is None: + e = getattr(content, "endTimeMs", None) + if s is not None: + global_start = s if global_start is None else min(global_start, s) + if e is not None: + global_end = e if global_end is None else max(global_end, e) + if global_start is not None and global_end is not None: + extracted["duration_seconds"] = round((global_end - global_start) / 1000, 1) + + is_multi_segment = len(contents) > 1 + + # --- Single-segment: flat output (documents, images, short audio) --- + if not is_multi_segment: + if "markdown" in output_sections and contents[0].markdown: + extracted["markdown"] = contents[0].markdown + if "fields" in output_sections and contents[0].fields: + fields: dict[str, object] = {} + for name, field in contents[0].fields.items(): + entry_dict: dict[str, object] = { + "type": getattr(field, "type", None), + "value": extract_field_value(field), + } + confidence = getattr(field, "confidence", None) + if confidence is not None: + entry_dict["confidence"] = confidence + fields[name] = entry_dict + if fields: + extracted["fields"] = fields + # Content-level category (e.g. from classifier analyzers) + category = getattr(contents[0], "category", None) + if category: + extracted["category"] = category + return extracted + + # --- Multi-segment: per-segment output (video scenes, long audio) --- + # Each segment keeps its own markdown + fields together so the LLM can + # see which fields (e.g. Summary) belong to which part of the content. + segments_out: list[dict[str, object]] = [] + md_parts: list[str] = [] # also collect for top-level concatenated markdown + + for content in contents: + seg: dict[str, object] = {} + + # Time range for this segment + s = getattr(content, "start_time_ms", None) + if s is None: + s = getattr(content, "startTimeMs", None) + e = getattr(content, "end_time_ms", None) + if e is None: + e = getattr(content, "endTimeMs", None) + if s is not None: + seg["start_time_s"] = round(s / 1000, 1) + if e is not None: + seg["end_time_s"] = round(e / 1000, 1) + + # Per-segment markdown + if "markdown" in output_sections and content.markdown: + seg["markdown"] = content.markdown + md_parts.append(content.markdown) + + # Per-segment fields + if "fields" in output_sections and content.fields: + seg_fields: dict[str, object] = {} + for name, field in content.fields.items(): + seg_entry: dict[str, object] = { + "type": getattr(field, "type", None), + "value": extract_field_value(field), + } + confidence = getattr(field, "confidence", None) + if confidence is not None: + seg_entry["confidence"] = confidence + seg_fields[name] = seg_entry + if seg_fields: + seg["fields"] = seg_fields + + # Per-segment category (e.g. from classifier analyzers) + category = getattr(content, "category", None) + if category: + seg["category"] = category + + segments_out.append(seg) + + extracted["segments"] = segments_out + + # Top-level concatenated markdown (used by file_search for vector store upload) + if md_parts: + extracted["markdown"] = "\n\n---\n\n".join(md_parts) + + return extracted + + +def extract_field_value(field: Any) -> object: + """Extract the plain Python value from a CU ``ContentField``. + + Uses the SDK's ``.value`` convenience property, which dynamically + reads the correct ``value_*`` attribute for each field type. + Object and array types are recursively flattened so that the + output contains only plain Python primitives (str, int, float, + date, dict, list) -- no SDK model objects or raw wire format + (``valueNumber``, ``spans``, ``source``, etc.). + """ + field_type = getattr(field, "type", None) + raw = getattr(field, "value", None) + + # Object fields -> recursively resolve nested sub-fields + if field_type == "object" and raw is not None and isinstance(raw, dict): + return {str(k): flatten_field(v) for k, v in cast(dict[str, Any], raw).items()} + + # Array fields -> list of flattened items (each with value + optional confidence) + if field_type == "array" and raw is not None and isinstance(raw, list): + return [flatten_field(item) for item in cast(list[Any], raw)] + + # Scalar fields (string, number, date, etc.) -- .value returns native Python type + return raw + + +def flatten_field(field: Any) -> object: + """Flatten a CU ``ContentField`` into a ``{type, value, confidence}`` dict. + + Used for sub-fields inside object and array types to preserve + per-field confidence scores. Confidence is omitted when ``None`` + to reduce token usage. + """ + field_type = getattr(field, "type", None) + value = extract_field_value(field) + confidence = getattr(field, "confidence", None) + + result: dict[str, object] = {"type": field_type, "value": value} + if confidence is not None: + result["confidence"] = confidence + return result + + +def format_result(filename: str, result: dict[str, object]) -> str: + """Format extracted CU result for LLM consumption. + + For multi-segment results (video/audio with ``segments``), each segment's + markdown and fields are grouped together so the LLM can see which fields + belong to which part of the content. + """ + kind = result.get("kind") + is_video = kind == "audioVisual" + is_audio = kind == "audio" + + # Header -- media-aware label + if is_video: + label = "Video analysis" + elif is_audio: + label = "Audio analysis" + else: + label = "Document analysis" + parts: list[str] = [f'{label} of "{filename}":'] + + # Media metadata line (duration, resolution) + meta_items: list[str] = [] + duration = result.get("duration_seconds") + if duration is not None: + mins, secs = divmod(int(duration), 60) # type: ignore[call-overload] + meta_items.append(f"Duration: {mins}:{secs:02d}") + resolution = result.get("resolution") + if resolution: + meta_items.append(f"Resolution: {resolution}") + if meta_items: + parts.append(" | ".join(meta_items)) + + # --- Multi-segment: format each segment with its own content + fields --- + raw_segments = result.get("segments") + segments: list[dict[str, object]] = ( + cast(list[dict[str, object]], raw_segments) if isinstance(raw_segments, list) else [] + ) + if segments: + for i, seg in enumerate(segments): + # Segment header with time range + start = seg.get("start_time_s") + end = seg.get("end_time_s") + if start is not None and end is not None: + s_min, s_sec = divmod(int(start), 60) # type: ignore[call-overload] + e_min, e_sec = divmod(int(end), 60) # type: ignore[call-overload] + parts.append(f"\n### Segment {i + 1} ({s_min}:{s_sec:02d} - {e_min}:{e_sec:02d})") + else: + parts.append(f"\n### Segment {i + 1}") + + # Segment markdown + seg_md = seg.get("markdown") + if seg_md: + parts.append(f"\n```markdown\n{seg_md}\n```") + + # Segment fields + seg_fields = seg.get("fields") + if isinstance(seg_fields, dict) and seg_fields: + fields_json = json.dumps(seg_fields, indent=2, default=str) + parts.append(f"\n**Fields:**\n```json\n{fields_json}\n```") + + return "\n".join(parts) + + # --- Single-segment: flat format --- + fields_raw = result.get("fields") + fields: dict[str, object] = cast(dict[str, object], fields_raw) if isinstance(fields_raw, dict) else {} + + # For audio: promote Summary field as prose before markdown + if is_audio and fields: + summary_field = fields.get("Summary") + if isinstance(summary_field, dict): + sf = cast(dict[str, object], summary_field) + if sf.get("value"): + parts.append(f"\n## Summary\n\n{sf['value']}") + + # Markdown content + markdown = result.get("markdown") + if markdown: + parts.append(f"\n## Content\n\n```markdown\n{markdown}\n```") + + # Fields section + if fields: + remaining = dict(fields) + if is_audio: + remaining = {k: v for k, v in remaining.items() if k != "Summary"} + if remaining: + fields_json = json.dumps(remaining, indent=2, default=str) + parts.append(f"\n## Extracted Fields\n\n```json\n{fields_json}\n```") + + return "\n".join(parts) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py new file mode 100644 index 0000000000..a9526f6ebc --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""File search backend abstraction for vector store file operations. + +Provides a unified interface for uploading CU-extracted content to +vector stores across different LLM clients. Two implementations: + +- ``OpenAIFileSearchBackend`` — for ``OpenAIChatClient`` (Responses API) +- ``FoundryFileSearchBackend`` — for ``FoundryChatClient`` (Responses API via Azure) + +Both share the same OpenAI-compatible vector store file API but differ +in the file upload ``purpose`` value. + +Vector store creation, tool construction, and lifecycle management are +the caller's responsibility — the backend only handles file upload/delete. +""" + +from __future__ import annotations + +import io +from abc import ABC, abstractmethod +from typing import Any + + +class FileSearchBackend(ABC): + """Abstract interface for vector store file operations. + + Implementations handle the differences between OpenAI and Foundry + file upload APIs (e.g., different ``purpose`` values). + + Vector store creation, deletion, and ``file_search`` tool construction + are **not** part of this interface — those are managed by the caller. + """ + + @abstractmethod + async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str: + """Upload a file to a vector store and return the file ID.""" + + @abstractmethod + async def delete_file(self, file_id: str) -> None: + """Delete a previously uploaded file by ID.""" + + +class _OpenAICompatBackend(FileSearchBackend): + """Shared base for OpenAI-compatible file upload backends. + + Both OpenAI and Foundry use the same ``client.files.*`` and + ``client.vector_stores.files.*`` API surface. Subclasses only + override the file upload ``purpose``. + """ + + _FILE_PURPOSE: str # Subclasses must set this + + def __init__(self, client: Any) -> None: + self._client = client + + async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str: + uploaded = await self._client.files.create( + file=(filename, io.BytesIO(content)), + purpose=self._FILE_PURPOSE, + ) + # Use create_and_poll to wait for indexing to complete before returning. + # Without this, file_search queries may return no results immediately + # after upload because the vector store index isn't ready yet. + await self._client.vector_stores.files.create_and_poll( + vector_store_id=vector_store_id, + file_id=uploaded.id, + ) + return uploaded.id # type: ignore[no-any-return] + + async def delete_file(self, file_id: str) -> None: + await self._client.files.delete(file_id) + + +class OpenAIFileSearchBackend(_OpenAICompatBackend): + """File search backend for OpenAI Responses API. + + Use with ``OpenAIChatClient`` or ``AzureOpenAIResponsesClient``. + Requires an ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client. + + Args: + client: An async OpenAI client (``AsyncOpenAI`` or ``AsyncAzureOpenAI``) + that supports ``client.files.*`` and ``client.vector_stores.*`` APIs. + """ + + _FILE_PURPOSE = "user_data" + + +class FoundryFileSearchBackend(_OpenAICompatBackend): + """File search backend for Azure AI Foundry. + + Use with ``FoundryChatClient``. Requires the OpenAI-compatible client + obtained from ``FoundryChatClient.client`` (i.e., + ``project_client.get_openai_client()``). + + Args: + client: The OpenAI-compatible async client from a ``FoundryChatClient`` + (access via ``foundry_client.client``). + """ + + _FILE_PURPOSE = "assistants" diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py new file mode 100644 index 0000000000..c938c05f12 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Literal, TypedDict + +from ._file_search import FileSearchBackend, FoundryFileSearchBackend, OpenAIFileSearchBackend + + +class DocumentStatus(str, Enum): + """Analysis lifecycle state of a tracked document.""" + + ANALYZING = "analyzing" + """CU analysis is in progress (deferred to background).""" + + UPLOADING = "uploading" + """Analysis complete; vector store upload + indexing is in progress.""" + + READY = "ready" + """Analysis (and upload, if applicable) completed successfully.""" + + FAILED = "failed" + """Analysis or upload failed.""" + + +AnalysisSection = Literal["markdown", "fields"] +"""Which sections of the CU output to pass to the LLM. + +- ``"markdown"``: Full document text with tables as HTML, reading order preserved. +- ``"fields"``: Extracted typed fields with confidence scores (when available). +""" + + +class DocumentEntry(TypedDict): + """Tracks the analysis state of a single document in session state.""" + + status: DocumentStatus + filename: str + media_type: str + analyzer_id: str + analyzed_at: str | None + analysis_duration_s: float | None + upload_duration_s: float | None + result: dict[str, object] | None + error: str | None + + +@dataclass +class FileSearchConfig: + """Configuration for uploading CU-extracted content to an existing vector store. + + When provided to ``ContentUnderstandingContextProvider``, analyzed document + markdown is automatically uploaded to the specified vector store and the + given ``file_search`` tool is registered on the context. This enables + token-efficient RAG retrieval on follow-up turns for large documents. + + The caller is responsible for creating and managing the vector store and + the ``file_search`` tool. Use :meth:`from_openai` or :meth:`from_foundry` + factory methods for convenience. + + Args: + backend: A ``FileSearchBackend`` that handles file upload/delete + operations for the target vector store. + vector_store_id: The ID of a pre-existing vector store to upload to. + file_search_tool: A ``file_search`` tool object created via the LLM + client's ``get_file_search_tool()`` factory method. This is + registered on the context via ``extend_tools`` so the LLM can + retrieve uploaded content. + """ + + backend: FileSearchBackend + vector_store_id: str + file_search_tool: Any + + @staticmethod + def from_openai( + client: Any, + *, + vector_store_id: str, + file_search_tool: Any, + ) -> FileSearchConfig: + """Create a config for OpenAI Responses API (``OpenAIChatClient``). + + Args: + client: An ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client. + vector_store_id: The ID of the vector store to upload to. + file_search_tool: Tool from ``OpenAIChatClient.get_file_search_tool()``. + """ + return FileSearchConfig( + backend=OpenAIFileSearchBackend(client), + vector_store_id=vector_store_id, + file_search_tool=file_search_tool, + ) + + @staticmethod + def from_foundry( + client: Any, + *, + vector_store_id: str, + file_search_tool: Any, + ) -> FileSearchConfig: + """Create a config for Azure AI Foundry (``FoundryChatClient``). + + Args: + client: The OpenAI-compatible client from ``FoundryChatClient.client``. + vector_store_id: The ID of the vector store to upload to. + file_search_tool: Tool from ``FoundryChatClient.get_file_search_tool()``. + """ + return FileSearchConfig( + backend=FoundryFileSearchBackend(client), + vector_store_id=vector_store_id, + file_search_tool=file_search_tool, + ) diff --git a/python/packages/azure-contentunderstanding/pyproject.toml b/python/packages/azure-contentunderstanding/pyproject.toml new file mode 100644 index 0000000000..f8c83b8d93 --- /dev/null +++ b/python/packages/azure-contentunderstanding/pyproject.toml @@ -0,0 +1,100 @@ +[project] +name = "agent-framework-azure-contentunderstanding" +description = "Azure Content Understanding integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260401" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0,<2", + "azure-ai-contentunderstanding>=1.0.0,<1.1", + "aiohttp>=3.9,<4", + "filetype>=1.2,<2", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint.per-file-ignores] +"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] +"samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"] + +[tool.coverage.run] +omit = ["**/__init__.py"] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_azure_contentunderstanding"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_azure_contentunderstanding"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_contentunderstanding" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_azure_contentunderstanding --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py b/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py new file mode 100644 index 0000000000..5f599e969b --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "azure-identity", +# ] +# /// +# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, Content, Message +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +load_dotenv() + +""" +Document Q&A — PDF upload with CU-powered extraction + +This sample demonstrates the simplest CU integration: upload a PDF and +ask questions about it. Azure Content Understanding extracts structured +markdown with table preservation — superior to LLM-only vision for +scanned PDFs, handwritten content, and complex layouts. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +# Path to a sample PDF — uses the shared sample asset if available, +# otherwise falls back to a public URL +SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + credential = AzureCliCredential() + + # Set up Azure Content Understanding context provider + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", # RAG-optimized document analyzer + max_wait=None, # wait until CU analysis finishes (no background deferral) + ) + + # Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # Create agent with CU context provider. + # The provider extracts document content via CU and injects it into the + # LLM context so the agent can answer questions about the document. + async with cu: + agent = Agent( + client=client, + name="DocumentQA", + instructions=( + "You are a helpful document analyst. Use the analyzed document " + "content and extracted fields to answer questions precisely." + ), + context_providers=[cu], + ) + + # --- Turn 1: Upload PDF and ask a question --- + # 4. Upload PDF and ask questions + # The CU provider extracts markdown + fields from the PDF and injects + # the full content into context so the agent can answer precisely. + print("--- Upload PDF and ask questions ---") + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text( + "What is this document about? Who is the vendor, and what is the total amount due?" + ), + Content.from_data( + pdf_bytes, + "application/pdf", + # Always provide filename — used as the document key + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ) + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Upload PDF and ask questions --- +Agent: This document is an **invoice** for services and fees billed to + **MICROSOFT CORPORATION** (Invoice **INV-100**), including line items + (e.g., Consulting Services, Document Fee, Printing Fee) and a billing summary. + - **Vendor:** **CONTOSO LTD.** + - **Total amount due:** **$610.00** + [Input tokens: 988] +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py b/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py new file mode 100644 index 0000000000..46f01dc999 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "azure-identity", +# ] +# /// +# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +load_dotenv() + +""" +Multi-Turn Session — Cached results across turns + +This sample demonstrates multi-turn document Q&A using an AgentSession. +The session persists CU analysis results and conversation history across +turns so the agent can answer follow-up questions about previously +uploaded documents without re-analyzing them. + +Key concepts: + - AgentSession keeps CU state and conversation history across agent.run() calls + - Turn 1: CU analyzes the PDF and injects full content into context + - Turn 2: Unrelated question — agent answers from general knowledge + - Turn 3: Detailed question — agent uses document content from conversation + history (injected in Turn 1) to answer precisely + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", + max_wait=None, # wait until CU analysis finishes (no background deferral) + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and persistent session + async with cu: + agent = Agent( + client=client, + name="DocumentQA", + instructions=( + "You are a helpful document analyst. Use the analyzed document " + "content and extracted fields to answer questions precisely." + ), + context_providers=[cu], + ) + + # Create a persistent session — this keeps CU state across turns + session = AgentSession() + + # 4. Turn 1: Upload PDF + # CU analyzes the PDF and injects full content into context. + print("--- Turn 1: Upload PDF ---") + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text("What is this document about?"), + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ), + session=session, # <-- persist state across turns + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + # 5. Turn 2: Unrelated question + # No document needed — agent answers from general knowledge. + print("--- Turn 2: Unrelated question ---") + response = await agent.run("What is the capital of France?", session=session) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + # 6. Turn 3: Detailed follow-up + # The agent answers from the full document content that was injected + # into conversation history in Turn 1. No re-analysis or tool call needed. + print("--- Turn 3: Detailed follow-up ---") + response = await agent.run( + "What is the shipping address on the invoice?", + session=session, + ) + usage = response.usage_details or {} + print(f"Agent: {response}") + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload PDF --- +Agent: This document is an **invoice** from **CONTOSO LTD.** to **MICROSOFT + CORPORATION**. Amount Due: $610.00. Invoice INV-100, dated 11/15/2019. + [Input tokens: 975] + +--- Turn 2: Unrelated question --- +Agent: Paris. + [Input tokens: 1134] + +--- Turn 3: Detailed follow-up --- +Agent: Shipping address (SHIP TO): Microsoft Delivery, 123 Ship St, + Redmond WA, 98052. + [Input tokens: 1155] +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py b/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py new file mode 100644 index 0000000000..86062fdd1d --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py @@ -0,0 +1,188 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "azure-identity", +# ] +# /// +# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py + + +import asyncio +import os +import time +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +load_dotenv() + +""" +Multi-Modal Chat — PDF, audio, and video in a single turn + +This sample demonstrates CU's multi-modal capability: upload a PDF invoice, +an audio call recording, and a video file all at once. The provider analyzes +all three in parallel using the right CU analyzer for each media type. + +The provider auto-detects the media type and selects the right CU analyzer: + - PDF/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +# Local PDF from package assets +SAMPLE_PDF = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf" + +# Public audio/video from Azure CU samples repo (raw GitHub URLs) +_CU_ASSETS = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main" +AUDIO_URL = f"{_CU_ASSETS}/audio/callCenterRecording.mp3" +VIDEO_URL = f"{_CU_ASSETS}/videos/sdk_samples/FlightSimulator.mp4" + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + # No analyzer_id specified — the provider auto-detects from media type: + # PDF/images → prebuilt-documentSearch + # Audio → prebuilt-audioSearch + # Video → prebuilt-videoSearch + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + max_wait=None, # wait until each analysis finishes + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and session + async with cu: + agent = Agent( + client=client, + name="MultiModalAgent", + instructions=( + "You are a helpful assistant that can analyze documents, audio, " + "and video files. Answer questions using the extracted content." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # --- Turn 1: Upload all 3 modalities at once --- + # The provider analyzes all files in parallel using the appropriate + # CU analyzer for each media type. All results are injected into + # the same context so the agent can answer about all of them. + turn1_prompt = ( + "I'm uploading three files: an invoice PDF, a call center " + "audio recording, and a flight simulator video. " + "Give a brief summary of each file." + ) + print("--- Turn 1: Upload PDF + audio + video (parallel analysis) ---") + print(" (CU analysis may take a few minutes for these audio/video files...)") + print(f"User: {turn1_prompt}") + t0 = time.perf_counter() + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text(turn1_prompt), + Content.from_data( + SAMPLE_PDF.read_bytes(), + "application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + Content.from_uri( + AUDIO_URL, + media_type="audio/mp3", + additional_properties={"filename": "callCenterRecording.mp3"}, + ), + Content.from_uri( + VIDEO_URL, + media_type="video/mp4", + additional_properties={"filename": "FlightSimulator.mp4"}, + ), + ], + ), + session=session, + ) + elapsed = time.perf_counter() - t0 + usage = response.usage_details or {} + print(f" [Analyzed in {elapsed:.1f}s | Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 2: Detail question about the PDF --- + turn2_prompt = "What are the line items and their amounts on the invoice?" + print("--- Turn 2: PDF detail ---") + print(f"User: {turn2_prompt}") + response = await agent.run(turn2_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 3: Detail question about the audio --- + turn3_prompt = "What was the customer's issue in the call recording?" + print("--- Turn 3: Audio detail ---") + print(f"User: {turn3_prompt}") + response = await agent.run(turn3_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 4: Detail question about the video --- + turn4_prompt = "What key scenes or actions are shown in the flight simulator video?" + print("--- Turn 4: Video detail ---") + print(f"User: {turn4_prompt}") + response = await agent.run(turn4_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + # --- Turn 5: Cross-document question --- + turn5_prompt = ( + "Across all three files, which one contains financial data, " + "which one involves a customer interaction, and which one is " + "a visual demonstration?" + ) + print("--- Turn 5: Cross-document question ---") + print(f"User: {turn5_prompt}") + response = await agent.run(turn5_prompt, session=session) + usage = response.usage_details or {} + print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]") + print(f"Agent: {response}\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload PDF + audio + video (parallel analysis) --- +User: I'm uploading three files... + (CU analysis may take 1-2 minutes for audio/video files...) + [Analyzed in ~94s | Input tokens: ~2939] +Agent: ### invoice.pdf: An invoice from CONTOSO LTD. to MICROSOFT CORPORATION... + ### callCenterRecording.mp3: A customer service call about point balance... + ### FlightSimulator.mp4: A clip discussing neural text-to-speech... + +--- Turn 2-5: Detail and cross-document questions --- +(Agent answers from conversation history without re-analysis) +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py b/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py new file mode 100644 index 0000000000..80d00894eb --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py @@ -0,0 +1,195 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "azure-identity", +# "pydantic", +# ] +# /// +# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from agent_framework.foundry import ContentUnderstandingContextProvider + +load_dotenv() + +""" +Invoice Processing — Structured output with prebuilt-invoice analyzer + +This sample demonstrates CU's structured field extraction combined with +LLM structured output (Pydantic model). The prebuilt-invoice analyzer extracts +typed fields (VendorName, InvoiceTotal, DueDate, LineItems, etc.) with +confidence scores. We use output_sections=["fields"] only (no markdown needed) +since we want the LLM to produce a structured JSON response from the extracted +fields, not summarize document text. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf" + + +# Structured output model — the LLM will return JSON matching this schema +# Structured output models — the LLM returns JSON matching this schema. +# +# Note: the prebuilt-invoice analyzer extracts an extensive set of fields +# (VendorName, BillingAddress, ShippingAddress, TaxDetails, PONumber, etc.). +# This sample defines a simplified schema to extract only the fields of +# interest to the caller. The LLM maps the full CU field output to this +# subset automatically. +# Learn more about prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers + + +class LineItem(BaseModel): + description: str + quantity: float | None = None + unit_price: float | None = None + amount: float | None = None + + +class LowConfidenceField(BaseModel): + field_name: str + confidence: float + + +class InvoiceResult(BaseModel): + vendor_name: str + total_amount: float | None = None + currency: str = "USD" + due_date: str | None = None + line_items: list[LineItem] = Field(default_factory=list) + low_confidence_fields: list[LowConfidenceField] = Field( + default_factory=list, + description="Fields with confidence < 0.8, including their confidence score", + ) + + +async def main() -> None: + # 1. Set up credentials and CU context provider + credential = AzureCliCredential() + + # Default analyzer is prebuilt-documentSearch (RAG-optimized). + # Per-file override via additional_properties["analyzer_id"] lets us + # use prebuilt-invoice for structured field extraction on specific files. + # + # Only request "fields" (not "markdown") — we want the extracted typed + # fields for structured output, not the raw document text. + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", # default for all files + max_wait=None, # wait until CU analysis finishes + output_sections=["fields"], # fields only — structured output doesn't need markdown + ) + + # 2. Set up the LLM client + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 3. Create agent and session + async with cu: + agent = Agent( + client=client, + name="InvoiceProcessor", + instructions=( + "You are an invoice processing assistant. Extract invoice data from " + "the provided CU fields (JSON with confidence scores). Return structured " + "output matching the requested schema. Flag fields with confidence < 0.8 " + "in the low_confidence_fields list." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # 4. Upload an invoice PDF — uses structured output (Pydantic model) + print("--- Upload Invoice (Structured Output) ---") + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text( + "Process this invoice. Extract the vendor name, total amount, due date, and all line items." + ), + Content.from_data( + pdf_bytes, + "application/pdf", + # Per-file analyzer override: use prebuilt-invoice for + # structured field extraction (VendorName, InvoiceTotal, etc.) + # instead of the provider default (prebuilt-documentSearch). + additional_properties={ + "filename": SAMPLE_PDF_PATH.name, + "analyzer_id": "prebuilt-invoice", + }, + ), + ], + ), + session=session, + options={"response_format": InvoiceResult}, + ) + + # Parse the structured output from JSON text + try: + invoice = InvoiceResult.model_validate_json(response.text) + print(f"Vendor: {invoice.vendor_name}") + print(f"Total: {invoice.currency} {invoice.total_amount}") + print(f"Due date: {invoice.due_date}") + print(f"Line items ({len(invoice.line_items)}):") + for item in invoice.line_items: + print(f" - {item.description}: {item.amount}") + if invoice.low_confidence_fields: + print("⚠ Low confidence fields:") + for f in invoice.low_confidence_fields: + print(f" - {f.field_name}: {f.confidence:.3f}") + except Exception: + print(f"Agent (raw): {response.text}\n") + + # 5. Follow-up: free-text question about the invoice + print("\n--- Follow-up (Free Text) ---") + response = await agent.run( + "What is the payment term? Are there any fields with low confidence?", + session=session, + ) + print(f"Agent: {response}\n") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Upload Invoice (Structured Output) --- +Vendor: CONTOSO LTD. +Total: USD 110.0 +Due date: 2019-12-15 +Line items (3): + - Consulting Services: 60.0 + - Document Fee: 30.0 + - Printing Fee: 10.0 +⚠ Low confidence: VendorName, CustomerName + +--- Follow-up (Free Text) --- +Agent: The payment terms are not explicitly stated on the invoice... +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py b/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py new file mode 100644 index 0000000000..ba3802bf54 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-contentunderstanding", +# "agent-framework-foundry", +# "azure-identity", +# ] +# /// +# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py + + +import asyncio +import os +from pathlib import Path + +from agent_framework import Agent, AgentSession, Content, Message +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, + FoundryChatClient, +) +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +""" +Large Document + file_search RAG — CU extraction + OpenAI vector store + +For large documents (100+ pages) or long audio/video, injecting the full +CU-extracted content into the LLM context is impractical. This sample shows +how to use the built-in file_search integration: CU extracts markdown and +automatically uploads it to an OpenAI vector store for token-efficient RAG. + +When ``FileSearchConfig`` is provided, the provider: + 1. Extracts markdown via CU (handles scanned PDFs, audio, video) + 2. Uploads the extracted markdown to a vector store + 3. Registers a ``file_search`` tool on the agent context + 4. Cleans up the vector store on close + +Architecture: + Large PDF -> CU extracts markdown -> auto-upload to vector store -> file_search + Follow-up -> file_search retrieves top-k chunks -> LLM answers + +NOTE: Requires an async OpenAI client for vector store operations. + +This sample uses a single small invoice PDF for simplicity. In practice, +you can upload multiple files in the same session (each is indexed +separately in the vector store), and this pattern is most valuable for +large documents (up to 300 pages), long audio recordings, or video files +where full-context injection would exceed the LLM's context window. +CU supports PDFs up to 300 pages / 200 MB, and audio files up to 300 MB +— see the full service limits: +https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL +""" + +SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf" + + +async def main() -> None: + # 1. Set up credentials and LLM client + credential = AzureCliCredential() + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ) + + # 2. Get the async OpenAI client from FoundryChatClient for vector store operations + openai_client = client.client + + # 3. Create vector store and file_search tool + vector_store = await openai_client.vector_stores.create( + name="cu_large_doc_demo", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id]) + + # 4. Configure CU provider with file_search integration + # When file_search is set, CU-extracted markdown is automatically uploaded + # to the vector store and the file_search tool is registered on the context. + cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=credential, + analyzer_id="prebuilt-documentSearch", + max_wait=None, # wait until CU analysis + vector store upload finishes + file_search=FileSearchConfig.from_foundry( + openai_client, + vector_store_id=vector_store.id, + file_search_tool=file_search_tool, + ), + ) + + pdf_bytes = SAMPLE_PDF_PATH.read_bytes() + + # The provider handles everything: CU extraction + vector store upload + file_search tool + async with cu: + agent = Agent( + client=client, + name="LargeDocAgent", + instructions=( + "You are a document analyst. Use the file_search tool to find " + "relevant sections from the document and answer precisely. " + "Cite specific sections when answering." + ), + context_providers=[cu], + ) + + session = AgentSession() + + # Turn 1: Upload — CU extracts and uploads to vector store automatically + print("--- Turn 1: Upload document ---") + response = await agent.run( + Message( + role="user", + contents=[ + Content.from_text("What are the key points in this document?"), + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={"filename": SAMPLE_PDF_PATH.name}, + ), + ], + ), + session=session, + ) + print(f"Agent: {response}\n") + + # Turn 2: Follow-up — file_search retrieves relevant chunks (token efficient) + print("--- Turn 2: Follow-up (RAG) ---") + response = await agent.run( + "What numbers or financial metrics are mentioned?", + session=session, + ) + print(f"Agent: {response}\n") + + # Explicitly delete the vector store created for this sample + await openai_client.vector_stores.delete(vector_store.id) + print("Done. Vector store deleted.") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +--- Turn 1: Upload document --- +Agent: An invoice from Contoso Ltd. to Microsoft Corporation (INV-100). + Line items: Consulting Services $60, Document Fee $30, Printing Fee $10. + Subtotal $100, Sales tax $10, Total $110, Previous balance $500, Amount due $610. + +--- Turn 2: Follow-up (RAG) --- +Agent: Subtotal $100.00, Sales tax $10.00, Total $110.00, + Previous unpaid balance $500.00, Amount due $610.00. + Line items: 2 hours @ $30 = $60, 3 @ $10 = $30, 10 pages @ $1 = $10. + +Done. Vector store cleaned up automatically. +""" diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md new file mode 100644 index 0000000000..12263ce50f --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md @@ -0,0 +1,33 @@ +# DevUI Multi-Modal Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding. + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + uv run poe devui --agent packages/azure-contentunderstanding/samples/devui_multimodal_agent + ``` + +4. Open the DevUI URL in your browser and start uploading files. + +## What You Can Do + +- **Upload PDFs** — including scanned/image-based PDFs that LLM vision struggles with +- **Upload images** — handwritten notes, infographics, charts +- **Upload audio** — meeting recordings, call center calls (transcription with speaker ID) +- **Upload video** — product demos, training videos (frame extraction + transcription) +- **Ask questions** across all uploaded documents +- **Check status** — "which documents are ready?" uses the auto-registered `list_documents()` tool diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py new file mode 100644 index 0000000000..3ca9ea7e09 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent with Azure Content Understanding.""" + +from .agent import agent + +__all__ = ["agent"] diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py new file mode 100644 index 0000000000..018b9232f5 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — file upload + CU-powered analysis. + +This agent uses Azure Content Understanding to analyze uploaded files +(PDFs, scanned documents, handwritten images, audio recordings, video) +and answer questions about them through the DevUI web interface. + +Unlike the standard azure_responses_agent which sends files directly to the LLM, +this agent uses CU for structured extraction — superior for scanned PDFs, +handwritten content, audio transcription, and video analysis. + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + uv run poe devui --agent packages/azure-contentunderstanding/samples/devui_multimodal_agent +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +load_dotenv() + +# --- Auth --- +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential + +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + # max_wait controls how long before_run() waits for CU analysis before + # deferring to background. For interactive DevUI use, a short timeout + # (e.g. 5s) keeps the chat responsive — the agent tells the user the + # file is still being analyzed and resolves it on the next turn. + # Use max_wait=None to always wait for analysis to complete. + max_wait=5.0, +) + +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=_credential, +) + +agent = Agent( + client=client, + name="MultiModalDocAgent", + instructions=( + "You are a helpful document analysis assistant. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding. " + "Use list_documents() to check which documents are ready, pending, or failed " + "and to see which files are available for answering questions. " + "Tell the user if any documents are still being analyzed. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md new file mode 100644 index 0000000000..0acb10a0d3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md @@ -0,0 +1,51 @@ +# DevUI File Search Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + OpenAI file_search RAG. + +## How It Works + +1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat +2. **CU analyzes** the file — auto-selects the right analyzer per media type +3. **Markdown extracted** by CU is uploaded to an OpenAI vector store +4. **file_search** tool is registered — LLM retrieves top-k relevant chunks +5. **Ask questions** across all uploaded documents with token-efficient RAG + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/ + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + devui packages/azure-contentunderstanding/samples/devui_azure_openai_file_search_agent + ``` + +4. Open the DevUI URL in your browser and start uploading files. + +## Supported File Types + +| Type | Formats | CU Analyzer (auto-detected) | +|------|---------|----------------------------| +| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` | +| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` | +| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` | +| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` | + +## vs. devui_multimodal_agent + +| Feature | multimodal_agent | file_search_agent | +|---------|-----------------|-------------------| +| CU extraction | ✅ Full content injected | ✅ Content indexed in vector store | +| RAG | ❌ | ✅ file_search retrieves top-k chunks | +| Large docs (100+ pages) | ⚠️ May exceed context window | ✅ Token-efficient | +| Multiple large files | ⚠️ Context overflow risk | ✅ All indexed, searchable | +| Best for | Small docs, quick inspection | Large docs, multi-file Q&A | diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py new file mode 100644 index 0000000000..92f4181db4 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent with CU + file_search RAG.""" + +from .agent import agent + +__all__ = ["agent"] diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py new file mode 100644 index 0000000000..aae1826173 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — CU extraction + file_search RAG. + +This agent combines Azure Content Understanding with OpenAI file_search +for token-efficient RAG over large or multi-modal documents. + +Upload flow: + 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video) + 2. Extracted markdown is auto-uploaded to an OpenAI vector store + 3. file_search tool is registered so the LLM retrieves top-k chunks + 4. Vector store is configured to auto-expire after inactivity + +This is ideal for large documents (100+ pages), long audio recordings, +or multiple files in the same conversation where full-context injection +would exceed the LLM's context window. + +Analyzer auto-detection: + When no analyzer_id is specified, the provider auto-selects the + appropriate CU analyzer based on media type: + - Documents/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + devui packages/azure-contentunderstanding/samples/devui_azure_openai_file_search_agent +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, + FoundryChatClient, +) +from azure.ai.projects import AIProjectClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +# --- Auth --- +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential + +_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + +# --- LLM client + sync vector store setup --- +# DevUI loads agent modules synchronously at startup while an event loop is already +# running, so we cannot use async APIs here. A sync AIProjectClient is used for +# one-time vector store creation; runtime file uploads use client.client (async). +client = FoundryChatClient( + project_endpoint=_endpoint, + model=os.environ["FOUNDRY_MODEL"], + credential=_credential, +) + +_sync_project = AIProjectClient(endpoint=_endpoint, credential=_credential) # type: ignore[arg-type] +_sync_openai = _sync_project.get_openai_client() +_vector_store = _sync_openai.vector_stores.create( + name="devui_cu_file_search", + expires_after={"anchor": "last_active_at", "days": 1}, +) +_sync_openai.close() + +_file_search_tool = client.get_file_search_tool( + vector_store_ids=[_vector_store.id], + max_num_results=3, # limit chunks to reduce input token usage +) + +# --- CU context provider with file_search --- +# client.client is the async OpenAI client used for runtime file uploads. +# No analyzer_id → auto-selects per media type (documents, audio, video) +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + file_search=FileSearchConfig.from_foundry( + client.client, # reuse the LLM client's internal AsyncAzureOpenAI for file uploads + vector_store_id=_vector_store.id, + file_search_tool=_file_search_tool, + ), +) + +agent = Agent( + client=client, + name="FileSearchDocAgent", + instructions=( + "You are a helpful document analysis assistant with RAG capabilities. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding " + "and indexed in a vector store for efficient retrieval. " + "Analysis takes time (seconds for documents, longer for audio/video) — if a document " + "is still pending, let the user know and suggest they ask again shortly. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "Multiple files can be uploaded and queried in the same conversation. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md new file mode 100644 index 0000000000..b72ed62327 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md @@ -0,0 +1,34 @@ +# DevUI Foundry File Search Agent + +Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Foundry file_search RAG. + +This is the **Foundry** variant. For the Azure OpenAI Responses API variant, see `devui_azure_openai_file_search_agent`. + +## How It Works + +1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat +2. **CU analyzes** the file — auto-selects the right analyzer per media type +3. **Markdown extracted** by CU is uploaded to a Foundry vector store +4. **file_search** tool is registered — LLM retrieves top-k relevant chunks +5. **Ask questions** across all uploaded documents with token-efficient RAG + +## Setup + +1. Set environment variables (or create a `.env` file in `python/`): + ```bash + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/ + FOUNDRY_MODEL=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/ + ``` + +2. Log in with Azure CLI: + ```bash + az login + ``` + +3. Run with DevUI: + ```bash + devui packages/azure-contentunderstanding/samples/devui_foundry_file_search_agent + ``` + +4. Open the DevUI URL in your browser and start uploading files. diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py new file mode 100644 index 0000000000..818abc2a23 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent — CU extraction + file_search RAG via Azure AI Foundry. + +This agent combines Azure Content Understanding with Foundry's file_search +for token-efficient RAG over large or multi-modal documents. + +Upload flow: + 1. CU extracts high-quality markdown (handles scanned PDFs, audio, video) + 2. Extracted markdown is uploaded to a Foundry vector store + 3. file_search tool is registered so the LLM retrieves top-k chunks + 4. Uploaded files are cleaned up on server shutdown + +This sample uses ``FoundryChatClient`` and ``FoundryFileSearchBackend``. +For the OpenAI Responses API variant, see ``devui_azure_openai_file_search_agent``. + +Analyzer auto-detection: + When no analyzer_id is specified, the provider auto-selects the + appropriate CU analyzer based on media type: + - Documents/images → prebuilt-documentSearch + - Audio → prebuilt-audioSearch + - Video → prebuilt-videoSearch + +Required environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint + FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1) + AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL + +Run with DevUI: + devui packages/azure-contentunderstanding/samples/devui_foundry_file_search_agent +""" + +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from openai import AzureOpenAI + +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, +) + +load_dotenv() + +# --- Auth --- +# AzureCliCredential for Foundry. CU API key optional if on a different resource. +_credential = AzureCliCredential() +_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY") +_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential + +# --- Foundry LLM client --- +client = FoundryChatClient( + project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""), + model=os.environ.get("FOUNDRY_MODEL", ""), + credential=_credential, +) + +# --- Create vector store (sync client to avoid event loop conflicts in DevUI) --- +_token = _credential.get_token("https://ai.azure.com/.default").token +_sync_openai = AzureOpenAI( + azure_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""), + azure_ad_token=_token, + api_version="2025-04-01-preview", +) +_vector_store = _sync_openai.vector_stores.create( + name="devui_cu_foundry_file_search", + expires_after={"anchor": "last_active_at", "days": 1}, +) +_sync_openai.close() + +_file_search_tool = client.get_file_search_tool( + vector_store_ids=[_vector_store.id], + max_num_results=3, # limit chunks to reduce input token usage +) + +# --- CU context provider with file_search --- +# No analyzer_id → auto-selects per media type (documents, audio, video) +cu = ContentUnderstandingContextProvider( + endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"], + credential=_cu_credential, + # max_wait is the combined budget for CU analysis + vector store upload. + # For file_search mode, 10s gives enough time for small documents to be + # analyzed and indexed in one turn. Larger files (audio, video) will + # be deferred to background and resolved on the next turn. + max_wait=10.0, + file_search=FileSearchConfig.from_foundry( + client.client, + vector_store_id=_vector_store.id, + file_search_tool=_file_search_tool, + ), +) + +agent = Agent( + client=client, + name="FoundryFileSearchDocAgent", + instructions=( + "You are a helpful document analysis assistant with RAG capabilities. " + "When a user uploads files, they are automatically analyzed using Azure Content Understanding " + "and indexed in a vector store for efficient retrieval. " + "Analysis takes time (seconds for documents, longer for audio/video) — if a document " + "is still pending, let the user know and suggest they ask again shortly. " + "You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. " + "Multiple files can be uploaded and queried in the same conversation. " + "When answering, cite specific content from the documents." + ), + context_providers=[cu], +) diff --git a/python/packages/azure-contentunderstanding/samples/README.md b/python/packages/azure-contentunderstanding/samples/README.md new file mode 100644 index 0000000000..0f4460e3f4 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/README.md @@ -0,0 +1,39 @@ +# Azure Content Understanding Samples + +These samples demonstrate how to use the `agent-framework-azure-contentunderstanding` package to add document, image, audio, and video understanding to your agents. + +## Prerequisites + +1. Azure CLI logged in: `az login` +2. Environment variables set (or `.env` file in the `python/` directory): + ``` + FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com + FOUNDRY_MODEL=gpt-4.1 + AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/ + ``` + +## Samples + +### 01-get-started — Script samples (easy → advanced) + +| # | Sample | Description | Run | +|---|--------|-------------|-----| +| 01 | [Document Q&A](01-get-started/01_document_qa.py) | Upload a PDF, ask questions with CU-powered extraction | `uv run samples/01-get-started/01_document_qa.py` | +| 02 | [Multi-Turn Session](01-get-started/02_multi_turn_session.py) | AgentSession persistence across turns | `uv run samples/01-get-started/02_multi_turn_session.py` | +| 03 | [Multi-Modal Chat](01-get-started/03_multimodal_chat.py) | PDF + audio + video parallel analysis | `uv run samples/01-get-started/03_multimodal_chat.py` | +| 04 | [Invoice Processing](01-get-started/04_invoice_processing.py) | Structured field extraction with prebuilt-invoice | `uv run samples/01-get-started/04_invoice_processing.py` | +| 05 | [Large Doc + file_search](01-get-started/05_large_doc_file_search.py) | CU extraction + OpenAI vector store RAG | `uv run samples/01-get-started/05_large_doc_file_search.py` | + +### 02-devui — Interactive web UI samples + +| # | Sample | Description | Run | +|---|--------|-------------|-----| +| 01 | [Multi-Modal Agent](02-devui/01-multimodal_agent/) | Web UI for file upload + CU-powered chat | `devui samples/02-devui/01-multimodal_agent` | +| 02a | [file_search (Azure OpenAI backend)](02-devui/02-file_search_agent/azure_openai_backend/) | DevUI with CU + Azure OpenAI vector store | `devui samples/02-devui/02-file_search_agent/azure_openai_backend` | +| 02b | [file_search (Foundry backend)](02-devui/02-file_search_agent/foundry_backend/) | DevUI with CU + Foundry vector store | `devui samples/02-devui/02-file_search_agent/foundry_backend` | + +## Install (preview) + +```bash +pip install --pre agent-framework-azure-contentunderstanding +``` diff --git a/python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf b/python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..812bcd9b30f3bce77b4e68fbd66d43a7cbba6bd4 GIT binary patch literal 151363 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 dict[str, Any]: + return json.loads((FIXTURES_DIR / name).read_text()) # type: ignore[no-any-return] + + +@pytest.fixture +def pdf_fixture_raw() -> dict[str, Any]: + return _load_fixture("analyze_pdf_result.json") + + +@pytest.fixture +def pdf_analysis_result(pdf_fixture_raw: dict[str, Any]) -> AnalysisResult: + return AnalysisResult(pdf_fixture_raw) + + +@pytest.fixture +def audio_fixture_raw() -> dict[str, Any]: + return _load_fixture("analyze_audio_result.json") + + +@pytest.fixture +def audio_analysis_result(audio_fixture_raw: dict[str, Any]) -> AnalysisResult: + return AnalysisResult(audio_fixture_raw) + + +@pytest.fixture +def invoice_fixture_raw() -> dict[str, Any]: + return _load_fixture("analyze_invoice_result.json") + + +@pytest.fixture +def invoice_analysis_result(invoice_fixture_raw: dict[str, Any]) -> AnalysisResult: + return AnalysisResult(invoice_fixture_raw) + + +@pytest.fixture +def video_fixture_raw() -> dict[str, Any]: + return _load_fixture("analyze_video_result.json") + + +@pytest.fixture +def video_analysis_result(video_fixture_raw: dict[str, Any]) -> AnalysisResult: + return AnalysisResult(video_fixture_raw) + + +@pytest.fixture +def image_fixture_raw() -> dict[str, Any]: + return _load_fixture("analyze_image_result.json") + + +@pytest.fixture +def image_analysis_result(image_fixture_raw: dict[str, Any]) -> AnalysisResult: + return AnalysisResult(image_fixture_raw) + + +@pytest.fixture +def mock_cu_client() -> AsyncMock: + """Create a mock ContentUnderstandingClient.""" + client = AsyncMock() + client.close = AsyncMock() + return client + + +def make_mock_poller(result: AnalysisResult) -> AsyncMock: + """Create a mock poller that returns the given result immediately.""" + poller = AsyncMock() + poller.result = AsyncMock(return_value=result) + poller.continuation_token = MagicMock(return_value="mock_continuation_token") + poller.done = MagicMock(return_value=True) + return poller + + +def make_slow_poller(result: AnalysisResult, delay: float = 10.0) -> MagicMock: + """Create a mock poller that simulates a timeout then eventually returns.""" + poller = MagicMock() + + async def slow_result() -> AnalysisResult: + await asyncio.sleep(delay) + return result + + poller.result = slow_result + poller.continuation_token = MagicMock(return_value="mock_slow_continuation_token") + poller.done = MagicMock(return_value=False) + return poller + + +def make_failing_poller(error: Exception) -> AsyncMock: + """Create a mock poller that raises an exception.""" + poller = AsyncMock() + poller.result = AsyncMock(side_effect=error) + return poller diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json new file mode 100644 index 0000000000..86227f3a45 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json @@ -0,0 +1,13 @@ +{ + "id": "synthetic-audio-001", + "status": "Succeeded", + "analyzer_id": "prebuilt-audioSearch", + "api_version": "2025-05-01-preview", + "created_at": "2026-03-21T10:05:00Z", + "contents": [ + { + "markdown": "## Call Center Recording\n\n**Duration:** 2 minutes 15 seconds\n**Speakers:** 2\n\n### Transcript\n\n**Speaker 1 (Agent):** Thank you for calling Contoso support. My name is Sarah. How can I help you today?\n\n**Speaker 2 (Customer):** Hi Sarah, I'm calling about my recent order number ORD-5678. It was supposed to arrive yesterday but I haven't received it.\n\n**Speaker 1 (Agent):** I'm sorry to hear that. Let me look up your order. Can you confirm your name and email address?\n\n**Speaker 2 (Customer):** Sure, it's John Smith, john.smith@example.com.\n\n**Speaker 1 (Agent):** Thank you, John. I can see your order was shipped on March 18th. It looks like there was a delay with the carrier. The updated delivery estimate is March 22nd.\n\n**Speaker 2 (Customer):** That's helpful, thank you. Is there anything I can do to track it?\n\n**Speaker 1 (Agent):** Yes, I'll send you a tracking link to your email right away. Is there anything else I can help with?\n\n**Speaker 2 (Customer):** No, that's all. Thanks for your help.\n\n**Speaker 1 (Agent):** You're welcome! Have a great day.", + "fields": {} + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json new file mode 100644 index 0000000000..0e86ef4354 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json @@ -0,0 +1,857 @@ +{ + "analyzerId": "prebuilt-documentSearch", + "apiVersion": "2025-11-01", + "createdAt": "2026-03-21T22:44:21Z", + "stringEncoding": "codePoint", + "warnings": [], + "contents": [ + { + "path": "input1", + "markdown": "# Contoso Q1 2025 Financial Summary\n\nTotal revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.\nOperating expenses were $31.2 million. Net profit was $11.5 million. The largest\nrevenue segment was Cloud Services at $19.3 million, followed by Professional\nServices at $14.8 million and Product Licensing at $8.6 million. Headcount at end of\nQ1 was 1,247 employees across 8 offices worldwide.\n", + "fields": { + "Summary": { + "type": "string", + "valueString": "The document provides a financial summary for Contoso in Q1 2025, reporting total revenue of $42.7 million, an 18% increase from Q1 2024. Operating expenses were $31.2 million, resulting in a net profit of $11.5 million. The largest revenue segment was Cloud Services with $19.3 million, followed by Professional Services at $14.8 million and Product Licensing at $8.6 million. The company had 1,247 employees across 8 offices worldwide at the end of Q1.", + "spans": [ + { + "offset": 37, + "length": 77 + }, + { + "offset": 115, + "length": 80 + }, + { + "offset": 196, + "length": 77 + }, + { + "offset": 274, + "length": 84 + }, + { + "offset": 359, + "length": 50 + } + ], + "confidence": 0.592, + "source": "D(1,212.0000,334.0000,1394.0000,334.0000,1394.0000,374.0000,212.0000,374.0000);D(1,213.0000,379.0000,1398.0000,379.0000,1398.0000,422.0000,213.0000,422.0000);D(1,212.0000,423.0000,1389.0000,423.0000,1389.0000,464.0000,212.0000,464.0000);D(1,213.0000,468.0000,1453.0000,468.0000,1453.0000,510.0000,213.0000,510.0000);D(1,213.0000,512.0000,1000.0000,512.0000,1000.0000,554.0000,213.0000,554.0000)" + } + }, + "kind": "document", + "startPageNumber": 1, + "endPageNumber": 1, + "unit": "pixel", + "pages": [ + { + "pageNumber": 1, + "angle": -0.0242, + "width": 1700, + "height": 2200, + "spans": [ + { + "offset": 0, + "length": 410 + } + ], + "words": [ + { + "content": "Contoso", + "span": { + "offset": 2, + "length": 7 + }, + "confidence": 0.99, + "source": "D(1,214,222,401,222,401,274,214,273)" + }, + { + "content": "Q1", + "span": { + "offset": 10, + "length": 2 + }, + "confidence": 0.957, + "source": "D(1,414,222,473,222,473,275,414,274)" + }, + { + "content": "2025", + "span": { + "offset": 13, + "length": 4 + }, + "confidence": 0.929, + "source": "D(1,494,222,607,222,607,276,494,275)" + }, + { + "content": "Financial", + "span": { + "offset": 18, + "length": 9 + }, + "confidence": 0.975, + "source": "D(1,624,222,819,223,819,277,624,276)" + }, + { + "content": "Summary", + "span": { + "offset": 28, + "length": 7 + }, + "confidence": 0.991, + "source": "D(1,836,223,1050,225,1050,279,836,277)" + }, + { + "content": "Total", + "span": { + "offset": 37, + "length": 5 + }, + "confidence": 0.996, + "source": "D(1,212,335,287,334,288,374,212,373)" + }, + { + "content": "revenue", + "span": { + "offset": 43, + "length": 7 + }, + "confidence": 0.994, + "source": "D(1,299,334,417,334,418,374,299,374)" + }, + { + "content": "for", + "span": { + "offset": 51, + "length": 3 + }, + "confidence": 0.994, + "source": "D(1,427,334,467,334,467,374,427,374)" + }, + { + "content": "Q1", + "span": { + "offset": 55, + "length": 2 + }, + "confidence": 0.944, + "source": "D(1,475,334,515,334,515,374,475,374)" + }, + { + "content": "2025", + "span": { + "offset": 58, + "length": 4 + }, + "confidence": 0.876, + "source": "D(1,528,334,604,334,604,374,529,374)" + }, + { + "content": "was", + "span": { + "offset": 63, + "length": 3 + }, + "confidence": 0.991, + "source": "D(1,613,334,672,334,672,374,613,374)" + }, + { + "content": "$", + "span": { + "offset": 67, + "length": 1 + }, + "confidence": 0.999, + "source": "D(1,681,334,698,334,698,374,681,374)" + }, + { + "content": "42.7", + "span": { + "offset": 68, + "length": 4 + }, + "confidence": 0.946, + "source": "D(1,700,334,765,334,765,374,700,374)" + }, + { + "content": "million", + "span": { + "offset": 73, + "length": 7 + }, + "confidence": 0.977, + "source": "D(1,775,334,867,334,867,374,776,374)" + }, + { + "content": ",", + "span": { + "offset": 80, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,870,334,877,334,877,374,870,374)" + }, + { + "content": "an", + "span": { + "offset": 82, + "length": 2 + }, + "confidence": 0.998, + "source": "D(1,888,334,922,334,922,374,888,374)" + }, + { + "content": "increase", + "span": { + "offset": 85, + "length": 8 + }, + "confidence": 0.991, + "source": "D(1,934,334,1058,335,1059,374,934,374)" + }, + { + "content": "of", + "span": { + "offset": 94, + "length": 2 + }, + "confidence": 0.982, + "source": "D(1,1069,335,1098,335,1098,374,1069,374)" + }, + { + "content": "18", + "span": { + "offset": 97, + "length": 2 + }, + "confidence": 0.963, + "source": "D(1,1108,335,1142,335,1142,374,1108,374)" + }, + { + "content": "%", + "span": { + "offset": 99, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,1143,335,1171,335,1171,374,1143,374)" + }, + { + "content": "over", + "span": { + "offset": 101, + "length": 4 + }, + "confidence": 0.946, + "source": "D(1,1181,335,1248,335,1248,374,1181,374)" + }, + { + "content": "Q1", + "span": { + "offset": 106, + "length": 2 + }, + "confidence": 0.875, + "source": "D(1,1256,335,1295,335,1295,374,1256,374)" + }, + { + "content": "2024", + "span": { + "offset": 109, + "length": 4 + }, + "confidence": 0.683, + "source": "D(1,1310,335,1384,335,1384,374,1310,374)" + }, + { + "content": ".", + "span": { + "offset": 113, + "length": 1 + }, + "confidence": 0.991, + "source": "D(1,1385,335,1394,335,1394,374,1385,374)" + }, + { + "content": "Operating", + "span": { + "offset": 115, + "length": 9 + }, + "confidence": 0.996, + "source": "D(1,213,380,358,380,358,422,213,422)" + }, + { + "content": "expenses", + "span": { + "offset": 125, + "length": 8 + }, + "confidence": 0.997, + "source": "D(1,369,380,513,379,513,421,369,421)" + }, + { + "content": "were", + "span": { + "offset": 134, + "length": 4 + }, + "confidence": 0.998, + "source": "D(1,521,379,595,379,595,421,521,421)" + }, + { + "content": "$", + "span": { + "offset": 139, + "length": 1 + }, + "confidence": 0.999, + "source": "D(1,603,379,620,379,620,421,603,421)" + }, + { + "content": "31.2", + "span": { + "offset": 140, + "length": 4 + }, + "confidence": 0.938, + "source": "D(1,623,379,686,379,686,421,623,421)" + }, + { + "content": "million", + "span": { + "offset": 145, + "length": 7 + }, + "confidence": 0.913, + "source": "D(1,696,379,790,379,790,421,696,421)" + }, + { + "content": ".", + "span": { + "offset": 152, + "length": 1 + }, + "confidence": 0.975, + "source": "D(1,793,379,800,379,800,421,793,421)" + }, + { + "content": "Net", + "span": { + "offset": 154, + "length": 3 + }, + "confidence": 0.976, + "source": "D(1,811,379,862,379,862,420,811,421)" + }, + { + "content": "profit", + "span": { + "offset": 158, + "length": 6 + }, + "confidence": 0.993, + "source": "D(1,871,379,947,379,947,420,871,420)" + }, + { + "content": "was", + "span": { + "offset": 165, + "length": 3 + }, + "confidence": 0.997, + "source": "D(1,954,379,1012,379,1012,420,953,420)" + }, + { + "content": "$", + "span": { + "offset": 169, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,1021,379,1039,379,1039,420,1021,420)" + }, + { + "content": "11.5", + "span": { + "offset": 170, + "length": 4 + }, + "confidence": 0.954, + "source": "D(1,1043,379,1106,379,1106,421,1043,420)" + }, + { + "content": "million", + "span": { + "offset": 175, + "length": 7 + }, + "confidence": 0.837, + "source": "D(1,1118,379,1208,379,1208,421,1118,421)" + }, + { + "content": ".", + "span": { + "offset": 182, + "length": 1 + }, + "confidence": 0.978, + "source": "D(1,1210,379,1217,379,1217,421,1210,421)" + }, + { + "content": "The", + "span": { + "offset": 184, + "length": 3 + }, + "confidence": 0.949, + "source": "D(1,1228,379,1285,379,1285,421,1228,421)" + }, + { + "content": "largest", + "span": { + "offset": 188, + "length": 7 + }, + "confidence": 0.978, + "source": "D(1,1295,379,1398,379,1398,421,1295,421)" + }, + { + "content": "revenue", + "span": { + "offset": 196, + "length": 7 + }, + "confidence": 0.995, + "source": "D(1,212,425,334,425,334,464,212,464)" + }, + { + "content": "segment", + "span": { + "offset": 204, + "length": 7 + }, + "confidence": 0.996, + "source": "D(1,344,425,472,424,472,464,344,464)" + }, + { + "content": "was", + "span": { + "offset": 212, + "length": 3 + }, + "confidence": 0.998, + "source": "D(1,480,424,541,424,541,464,480,464)" + }, + { + "content": "Cloud", + "span": { + "offset": 216, + "length": 5 + }, + "confidence": 0.997, + "source": "D(1,550,424,636,424,637,464,551,464)" + }, + { + "content": "Services", + "span": { + "offset": 222, + "length": 8 + }, + "confidence": 0.995, + "source": "D(1,647,424,774,424,774,464,647,464)" + }, + { + "content": "at", + "span": { + "offset": 231, + "length": 2 + }, + "confidence": 0.996, + "source": "D(1,784,424,812,424,812,464,784,464)" + }, + { + "content": "$", + "span": { + "offset": 234, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,820,424,837,424,837,464,820,464)" + }, + { + "content": "19.3", + "span": { + "offset": 235, + "length": 4 + }, + "confidence": 0.879, + "source": "D(1,840,424,903,423,903,463,840,464)" + }, + { + "content": "million", + "span": { + "offset": 240, + "length": 7 + }, + "confidence": 0.876, + "source": "D(1,915,423,1006,423,1006,463,915,463)" + }, + { + "content": ",", + "span": { + "offset": 247, + "length": 1 + }, + "confidence": 0.999, + "source": "D(1,1008,423,1015,423,1015,463,1008,463)" + }, + { + "content": "followed", + "span": { + "offset": 249, + "length": 8 + }, + "confidence": 0.978, + "source": "D(1,1026,423,1148,424,1148,463,1026,463)" + }, + { + "content": "by", + "span": { + "offset": 258, + "length": 2 + }, + "confidence": 0.986, + "source": "D(1,1160,424,1194,424,1194,463,1160,463)" + }, + { + "content": "Professional", + "span": { + "offset": 261, + "length": 12 + }, + "confidence": 0.965, + "source": "D(1,1204,424,1389,424,1389,463,1204,463)" + }, + { + "content": "Services", + "span": { + "offset": 274, + "length": 8 + }, + "confidence": 0.991, + "source": "D(1,213,469,341,469,341,510,213,510)" + }, + { + "content": "at", + "span": { + "offset": 283, + "length": 2 + }, + "confidence": 0.997, + "source": "D(1,352,469,380,469,380,510,352,510)" + }, + { + "content": "$", + "span": { + "offset": 286, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,388,469,405,469,405,510,388,510)" + }, + { + "content": "14.8", + "span": { + "offset": 287, + "length": 4 + }, + "confidence": 0.973, + "source": "D(1,410,469,472,469,472,510,410,510)" + }, + { + "content": "million", + "span": { + "offset": 292, + "length": 7 + }, + "confidence": 0.987, + "source": "D(1,483,469,575,469,575,510,483,510)" + }, + { + "content": "and", + "span": { + "offset": 300, + "length": 3 + }, + "confidence": 0.999, + "source": "D(1,585,469,638,469,638,510,585,510)" + }, + { + "content": "Product", + "span": { + "offset": 304, + "length": 7 + }, + "confidence": 0.995, + "source": "D(1,652,469,765,469,765,510,652,510)" + }, + { + "content": "Licensing", + "span": { + "offset": 312, + "length": 9 + }, + "confidence": 0.993, + "source": "D(1,777,469,914,469,914,510,777,510)" + }, + { + "content": "at", + "span": { + "offset": 322, + "length": 2 + }, + "confidence": 0.998, + "source": "D(1,925,469,953,469,953,510,925,510)" + }, + { + "content": "$", + "span": { + "offset": 325, + "length": 1 + }, + "confidence": 0.998, + "source": "D(1,961,469,978,469,978,510,961,510)" + }, + { + "content": "8.6", + "span": { + "offset": 326, + "length": 3 + }, + "confidence": 0.958, + "source": "D(1,980,469,1025,469,1025,510,980,510)" + }, + { + "content": "million", + "span": { + "offset": 330, + "length": 7 + }, + "confidence": 0.908, + "source": "D(1,1036,469,1128,468,1128,510,1036,510)" + }, + { + "content": ".", + "span": { + "offset": 337, + "length": 1 + }, + "confidence": 0.987, + "source": "D(1,1130,468,1137,468,1137,510,1130,510)" + }, + { + "content": "Headcount", + "span": { + "offset": 339, + "length": 9 + }, + "confidence": 0.934, + "source": "D(1,1150,468,1310,468,1310,510,1150,510)" + }, + { + "content": "at", + "span": { + "offset": 349, + "length": 2 + }, + "confidence": 0.993, + "source": "D(1,1318,468,1348,468,1348,510,1318,510)" + }, + { + "content": "end", + "span": { + "offset": 352, + "length": 3 + }, + "confidence": 0.947, + "source": "D(1,1355,468,1410,468,1410,510,1355,510)" + }, + { + "content": "of", + "span": { + "offset": 356, + "length": 2 + }, + "confidence": 0.974, + "source": "D(1,1419,468,1453,468,1453,509,1419,509)" + }, + { + "content": "Q1", + "span": { + "offset": 359, + "length": 2 + }, + "confidence": 0.931, + "source": "D(1,213,512,252,512,252,554,213,554)" + }, + { + "content": "was", + "span": { + "offset": 362, + "length": 3 + }, + "confidence": 0.847, + "source": "D(1,267,512,326,512,326,554,267,554)" + }, + { + "content": "1,247", + "span": { + "offset": 366, + "length": 5 + }, + "confidence": 0.523, + "source": "D(1,338,512,419,512,419,554,338,554)" + }, + { + "content": "employees", + "span": { + "offset": 372, + "length": 9 + }, + "confidence": 0.972, + "source": "D(1,429,513,591,512,591,554,429,554)" + }, + { + "content": "across", + "span": { + "offset": 382, + "length": 6 + }, + "confidence": 0.972, + "source": "D(1,601,512,697,512,697,554,601,554)" + }, + { + "content": "8", + "span": { + "offset": 389, + "length": 1 + }, + "confidence": 0.946, + "source": "D(1,708,512,725,512,725,553,708,554)" + }, + { + "content": "offices", + "span": { + "offset": 391, + "length": 7 + }, + "confidence": 0.95, + "source": "D(1,736,512,831,512,831,553,736,553)" + }, + { + "content": "worldwide", + "span": { + "offset": 399, + "length": 9 + }, + "confidence": 0.988, + "source": "D(1,840,512,989,512,989,552,840,553)" + }, + { + "content": ".", + "span": { + "offset": 408, + "length": 1 + }, + "confidence": 0.996, + "source": "D(1,991,512,1000,512,1000,552,991,552)" + } + ], + "lines": [ + { + "content": "Contoso Q1 2025 Financial Summary", + "source": "D(1,214,221,1050,225,1050,279,213,273)", + "span": { + "offset": 2, + "length": 33 + } + }, + { + "content": "Total revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.", + "source": "D(1,212,334,1394,335,1394,374,212,374)", + "span": { + "offset": 37, + "length": 77 + } + }, + { + "content": "Operating expenses were $31.2 million. Net profit was $11.5 million. The largest", + "source": "D(1,213,379,1398,378,1398,421,213,422)", + "span": { + "offset": 115, + "length": 80 + } + }, + { + "content": "revenue segment was Cloud Services at $19.3 million, followed by Professional", + "source": "D(1,212,424,1389,423,1389,463,212,464)", + "span": { + "offset": 196, + "length": 77 + } + }, + { + "content": "Services at $14.8 million and Product Licensing at $8.6 million. Headcount at end of", + "source": "D(1,213,469,1453,468,1453,510,213,511)", + "span": { + "offset": 274, + "length": 84 + } + }, + { + "content": "Q1 was 1,247 employees across 8 offices worldwide.", + "source": "D(1,213,512,1000,512,1000,554,213,554)", + "span": { + "offset": 359, + "length": 50 + } + } + ] + } + ], + "paragraphs": [ + { + "role": "title", + "content": "Contoso Q1 2025 Financial Summary", + "source": "D(1,214,219,1050,225,1050,279,213,273)", + "span": { + "offset": 0, + "length": 35 + } + }, + { + "content": "Total revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024. Operating expenses were $31.2 million. Net profit was $11.5 million. The largest revenue segment was Cloud Services at $19.3 million, followed by Professional Services at $14.8 million and Product Licensing at $8.6 million. Headcount at end of Q1 was 1,247 employees across 8 offices worldwide.", + "source": "D(1,212,334,1453,333,1454,553,212,554)", + "span": { + "offset": 37, + "length": 372 + } + } + ], + "sections": [ + { + "span": { + "offset": 0, + "length": 409 + }, + "elements": [ + "/paragraphs/0", + "/paragraphs/1" + ] + } + ], + "analyzerId": "prebuilt-documentSearch", + "mimeType": "image/png" + } + ] +} \ No newline at end of file diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json new file mode 100644 index 0000000000..076649f0dd --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json @@ -0,0 +1,114 @@ +{ + "analyzerId": "prebuilt-invoice", + "apiVersion": "2025-11-01", + "createdAt": "2026-03-21T22:44:33Z", + "stringEncoding": "codePoint", + "warnings": [], + "contents": [ + { + "markdown": "# Master Services Agreement\n\nClient: Alpine Industries Inc.\n\nContract Reference: MSA-2025-ALP-00847\n\nEffective Date: January 15, 2025\nPrepared for: Robert Chen, Chief Executive Officer, Alpine Industries Inc.\n\nAddress: 742 Evergreen Blvd, Denver, CO 80203\n\nThis Master Services Agreement (the 'Agreement') is entered into by and between Alpine Industries\nInc. (the 'Client') and TechServe Global Partners (the 'Provider'). This agreement governs the provision\nof managed technology services as descri", + "fields": { + "VendorName": { + "type": "string", + "valueString": "TechServe Global Partners", + "confidence": 0.71 + }, + "DueDate": { + "type": "date", + "valueDate": "2025-02-15", + "confidence": 0.793 + }, + "InvoiceDate": { + "type": "date", + "valueDate": "2025-01-15", + "confidence": 0.693 + }, + "InvoiceId": { + "type": "string", + "valueString": "INV-100", + "confidence": 0.489 + }, + "AmountDue": { + "type": "object", + "valueObject": { + "Amount": { + "type": "number", + "valueNumber": 610, + "confidence": 0.758 + }, + "CurrencyCode": { + "type": "string", + "valueString": "USD" + } + } + }, + "SubtotalAmount": { + "type": "object", + "valueObject": { + "Amount": { + "type": "number", + "valueNumber": 100, + "confidence": 0.902 + }, + "CurrencyCode": { + "type": "string", + "valueString": "USD" + } + } + }, + "LineItems": { + "type": "array", + "valueArray": [ + { + "type": "object", + "valueObject": { + "Description": { + "type": "string", + "valueString": "Consulting Services", + "confidence": 0.664 + }, + "Quantity": { + "type": "number", + "valueNumber": 2, + "confidence": 0.957 + }, + "UnitPrice": { + "type": "object", + "valueObject": { + "Amount": { + "type": "number", + "valueNumber": 30, + "confidence": 0.956 + }, + "CurrencyCode": { + "type": "string", + "valueString": "USD" + } + } + } + } + }, + { + "type": "object", + "valueObject": { + "Description": { + "type": "string", + "valueString": "Document Fee", + "confidence": 0.712 + }, + "Quantity": { + "type": "number", + "valueNumber": 3, + "confidence": 0.939 + } + } + } + ] + } + }, + "kind": "document", + "startPageNumber": 1, + "endPageNumber": 100 + } + ] +} \ No newline at end of file diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json new file mode 100644 index 0000000000..d5671616f9 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json @@ -0,0 +1,23 @@ +{ + "analyzerId": "prebuilt-documentSearch", + "apiVersion": "2025-11-01", + "createdAt": "2026-03-21T22:44:09Z", + "contents": [ + { + "path": "input1", + "markdown": "# Contoso Q1 2025 Financial Summary\n\nTotal revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.\nOperating expenses were $31.2 million. Net profit was $11.5 million. The largest\nrevenue segment was Cloud Services at $19.3 million, followed by Professional\nServices at $14.8 million and Product Licensing at $8.6 million. Headcount at end of\nQ1 was 1,247 employees across 8 offices worldwide.\n\n\n\n\n# Contoso Q2 2025 Financial Summary\n\nTotal revenue for Q2 2025 was $48.1 million, an increase of 22% over Q2 2024.\nOperating expenses were $33.9 million. Net profit was $14.2 million. Cloud Services\ngrew to $22.5 million, Professional Services was $15.7 million, and Product Licensing\nwas $9.9 million. The company opened a new office in Tokyo, bringing the total to 9\noffices. Headcount grew to 1,389 employees.\n\n\n\n\n## Contoso Product Roadmap 2025\n\nThree major product launches are planned for 2025: (1) Contoso CloudVault - an\nenterprise document storage solution, launching August 2025, with an expected price\nof $29.99/user/month. (2) Contoso DataPulse - a real-time analytics dashboard,\nlaunching October 2025. (3) Contoso SecureLink - a zero-trust networking product,\nlaunching December 2025. Total R&D; budget for 2025 is $18.4 million.\n\n\n\n\n# Contoso Employee Satisfaction Survey Results\n\nThe annual employee satisfaction survey was completed in March 2025 with a 87%\nresponse rate. Overall satisfaction score was 4.2 out of 5.0. Work-life balance scored\n3.8/5.0. Career growth opportunities scored 3.9/5.0. Compensation satisfaction\nscored 3.6/5.0. The top requested improvement was 'more flexible remote work\noptions' cited by 62% of respondents. Employee retention rate for the trailing 12\nmonths was 91%.\n\n\n\n\n## Contoso Partnership Announcements\n\nContoso announced three strategic partnerships in H1 2025: (1) A joint venture with\nMeridian Technologies for AI-powered document processing, valued at $5.2 million\nover 3 years. (2) A distribution agreement with Pacific Rim Solutions covering 12\ncountries in Asia-Pacific. (3) A technology integration partnership with NovaBridge\nSystems for unified identity management. The Chief Partnership Officer, Helena\nNakagawa, stated the partnerships are expected to generate an additional $15 million\nin revenue by 2027.\n", + "fields": { + "Summary": { + "type": "string", + "valueString": "The document provides a comprehensive overview of Contoso's key business metrics and initiatives for 2025, including financial performance for Q1 and Q2 with revenue, expenses, and profit details; a product roadmap with three major launches and R&D budget; employee satisfaction survey results highlighting scores and retention; and strategic partnership announcements expected to boost future revenue.", + "confidence": 0.46 + } + }, + "kind": "document", + "startPageNumber": 1, + "endPageNumber": 5, + "mimeType": "application/pdf", + "analyzerId": "prebuilt-documentSearch" + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json new file mode 100644 index 0000000000..e9834fa955 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json @@ -0,0 +1,51 @@ +{ + "id": "synthetic-video-001", + "status": "Succeeded", + "analyzer_id": "prebuilt-videoSearch", + "api_version": "2025-05-01-preview", + "created_at": "2026-03-21T10:15:00Z", + "contents": [ + { + "kind": "audioVisual", + "startTimeMs": 1000, + "endTimeMs": 14000, + "width": 640, + "height": 480, + "markdown": "# Video: 00:01.000 => 00:14.000\n\nTranscript\n```\nWEBVTT\n\n00:01.000 --> 00:05.000\nWelcome to the Contoso Product Demo.\n\n00:05.000 --> 00:14.000\nToday we'll be showcasing our latest cloud infrastructure management tool.\n```", + "fields": { + "Summary": { + "type": "string", + "valueString": "Introduction to the Contoso Product Demo showcasing the latest cloud infrastructure management tool." + } + } + }, + { + "kind": "audioVisual", + "startTimeMs": 15000, + "endTimeMs": 35000, + "width": 640, + "height": 480, + "markdown": "# Video: 00:15.000 => 00:35.000\n\nTranscript\n```\nWEBVTT\n\n00:15.000 --> 00:25.000\nAs you can see on the dashboard, the system provides real-time monitoring of all deployed resources.\n\n00:25.000 --> 00:35.000\nKey features include automated scaling, cost optimization, and security compliance monitoring.\n```", + "fields": { + "Summary": { + "type": "string", + "valueString": "Dashboard walkthrough covering real-time monitoring, automated scaling, cost optimization, and security compliance." + } + } + }, + { + "kind": "audioVisual", + "startTimeMs": 36000, + "endTimeMs": 42000, + "width": 640, + "height": 480, + "markdown": "# Video: 00:36.000 => 00:42.000\n\nTranscript\n```\nWEBVTT\n\n00:36.000 --> 00:42.000\nVisit contoso.com/cloud-manager to learn more and start your free trial.\n```", + "fields": { + "Summary": { + "type": "string", + "valueString": "Call to action directing viewers to contoso.com/cloud-manager for more information and a free trial." + } + } + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py b/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py new file mode 100644 index 0000000000..0e0dae439f --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py @@ -0,0 +1,2049 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import base64 +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from agent_framework import Content, Message, SessionContext +from agent_framework._sessions import AgentSession +from azure.ai.contentunderstanding.models import AnalysisResult + +from agent_framework_azure_contentunderstanding import ( + ContentUnderstandingContextProvider, + DocumentStatus, +) +from agent_framework_azure_contentunderstanding._detection import SUPPORTED_MEDIA_TYPES, derive_doc_key +from agent_framework_azure_contentunderstanding._extraction import format_result + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SAMPLE_PDF_BYTES = b"%PDF-1.4 fake content for testing" + + +def _make_mock_poller(result: AnalysisResult) -> AsyncMock: + """Create a mock poller that returns the given result immediately.""" + poller = AsyncMock() + poller.result = AsyncMock(return_value=result) + return poller + + +def _make_slow_poller(result: AnalysisResult, delay: float = 10.0) -> MagicMock: + """Create a mock poller that simulates a timeout then eventually returns.""" + poller = MagicMock() + + async def slow_result() -> AnalysisResult: + await asyncio.sleep(delay) + return result + + poller.result = slow_result + poller.continuation_token = MagicMock(return_value="mock_slow_continuation_token") + return poller + + +def _make_failing_poller(error: Exception) -> AsyncMock: + """Create a mock poller that raises an exception.""" + poller = AsyncMock() + poller.result = AsyncMock(side_effect=error) + return poller + + +def _make_data_uri(data: bytes, media_type: str) -> str: + return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}" + + +def _make_content_from_data(data: bytes, media_type: str, filename: str | None = None) -> Content: + props = {"filename": filename} if filename else None + return Content.from_data(data, media_type, additional_properties=props) + + +def _make_context(messages: list[Message]) -> SessionContext: + return SessionContext(input_messages=messages) + + +def _make_provider( + mock_client: AsyncMock | None = None, + **kwargs: Any, +) -> ContentUnderstandingContextProvider: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + **kwargs, + ) + if mock_client: + provider._client = mock_client # type: ignore[assignment] + return provider + + +def _make_mock_agent() -> MagicMock: + return MagicMock() + + +# =========================================================================== +# Test Classes +# =========================================================================== + + +class TestInit: + def test_default_values(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider.analyzer_id is None + assert provider.max_wait == 5.0 + assert provider.output_sections == ["markdown", "fields"] + assert provider.source_id == "azure_contentunderstanding" + + def test_custom_values(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://custom.cognitiveservices.azure.com/", + credential=AsyncMock(), + analyzer_id="prebuilt-invoice", + max_wait=10.0, + output_sections=["markdown"], + source_id="custom_cu", + ) + assert provider.analyzer_id == "prebuilt-invoice" + assert provider.max_wait == 10.0 + assert provider.output_sections == ["markdown"] + assert provider.source_id == "custom_cu" + + def test_max_wait_none(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + max_wait=None, + ) + assert provider.max_wait is None + + def test_endpoint_from_env_var(self, monkeypatch: Any) -> None: + """Endpoint can be loaded from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var.""" + monkeypatch.setenv( + "AZURE_CONTENTUNDERSTANDING_ENDPOINT", + "https://env-test.cognitiveservices.azure.com/", + ) + provider = ContentUnderstandingContextProvider(credential=AsyncMock()) + assert provider._endpoint == "https://env-test.cognitiveservices.azure.com/" + + def test_explicit_endpoint_overrides_env_var(self, monkeypatch: Any) -> None: + """Explicit endpoint kwarg takes priority over env var.""" + monkeypatch.setenv( + "AZURE_CONTENTUNDERSTANDING_ENDPOINT", + "https://env-test.cognitiveservices.azure.com/", + ) + provider = ContentUnderstandingContextProvider( + endpoint="https://explicit.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider._endpoint == "https://explicit.cognitiveservices.azure.com/" + + def test_missing_endpoint_raises(self) -> None: + """Missing endpoint (no kwarg, no env var) raises an error.""" + # Clear env var to ensure load_settings raises + import os + + import pytest as _pytest + from agent_framework.exceptions import SettingNotFoundError + + env_key = "AZURE_CONTENTUNDERSTANDING_ENDPOINT" + old_val = os.environ.pop(env_key, None) + try: + with _pytest.raises(SettingNotFoundError, match="endpoint"): + ContentUnderstandingContextProvider(credential=AsyncMock()) + finally: + if old_val is not None: + os.environ[env_key] = old_val + + def test_missing_credential_raises(self) -> None: + """Missing credential raises ValueError.""" + import pytest as _pytest + + with _pytest.raises(ValueError, match="credential is required"): + ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + ) + + +class TestAsyncContextManager: + async def test_aenter_returns_self(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + result = await provider.__aenter__() + assert result is provider + await provider.__aexit__(None, None, None) + + async def test_aexit_closes_client(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + mock_client = AsyncMock() + provider._client = mock_client # type: ignore[assignment] + await provider.__aexit__(None, None, None) + mock_client.close.assert_called_once() + + +class TestBeforeRunNewFile: + async def test_single_pdf_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's on this invoice?"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "invoice.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Document should be in state + assert "documents" in state + assert "invoice.pdf" in state["documents"] + assert state["documents"]["invoice.pdf"]["status"] == DocumentStatus.READY + + # Binary should be stripped from input + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/pdf" + + # Context should have messages injected + assert len(context.context_messages) > 0 + + async def test_url_input_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this document"), + Content.from_uri("https://example.com/report.pdf", media_type="application/pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # URL input should use begin_analyze + mock_cu_client.begin_analyze.assert_called_once() + assert "report.pdf" in state["documents"] + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + async def test_text_only_skipped(self, mock_cu_client: AsyncMock) -> None: + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message(role="user", contents=[Content.from_text("What's the weather?")]) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # No CU calls + mock_cu_client.begin_analyze.assert_not_called() + mock_cu_client.begin_analyze_binary.assert_not_called() + # No documents + assert state.get("documents", {}) == {} + + +class TestBeforeRunMultiFile: + async def test_two_files_both_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + image_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + side_effect=[ + _make_mock_poller(pdf_analysis_result), + _make_mock_poller(image_analysis_result), + ] + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Compare these documents"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc1.pdf"), + _make_content_from_data(b"\x89PNG fake", "image/png", "chart.png"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert len(state["documents"]) == 2 + assert state["documents"]["doc1.pdf"]["status"] == DocumentStatus.READY + assert state["documents"]["chart.png"]["status"] == DocumentStatus.READY + + +class TestBeforeRunTimeout: + async def test_exceeds_max_wait_defers_to_background( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_slow_poller(pdf_analysis_result, delay=10.0)) + provider = _make_provider(mock_client=mock_cu_client, max_wait=0.1) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "big_doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["big_doc.pdf"]["status"] == DocumentStatus.ANALYZING + assert "big_doc.pdf" in state.get("_pending_tokens", {}) + token_info = state["_pending_tokens"]["big_doc.pdf"] + assert "continuation_token" in token_info + assert "analyzer_id" in token_info + + # Context messages should mention analyzing + assert any("being analyzed" in m.text for msgs in context.context_messages.values() for m in msgs) + + +class TestBeforeRunPendingResolution: + async def test_pending_completes_on_next_turn( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + # Mock begin_analyze to return a completed poller when called with continuation_token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_123", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Is the report ready?")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + assert state["documents"]["report.pdf"]["result"] is not None + assert "report.pdf" not in state.get("_pending_tokens", {}) + + +class TestBeforeRunPendingFailure: + async def test_pending_task_failure_updates_state( + self, + mock_cu_client: AsyncMock, + ) -> None: + # Mock begin_analyze to raise when resuming from continuation token + mock_cu_client.begin_analyze = AsyncMock(side_effect=RuntimeError("CU service unavailable")) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = { + "_pending_tokens": { + "bad_doc.pdf": {"continuation_token": "tok_fail", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "bad_doc.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "bad_doc.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Check status")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["bad_doc.pdf"]["status"] == DocumentStatus.FAILED + assert "CU service unavailable" in (state["documents"]["bad_doc.pdf"]["error"] or "") + + +class TestDocumentKeyDerivation: + def test_filename_from_additional_properties(self) -> None: + content = _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "my_report.pdf") + key = derive_doc_key(content) + assert key == "my_report.pdf" + + def test_url_basename(self) -> None: + content = Content.from_uri("https://example.com/docs/annual_report.pdf", media_type="application/pdf") + key = derive_doc_key(content) + assert key == "annual_report.pdf" + + def test_content_hash_fallback(self) -> None: + content = Content.from_data(_SAMPLE_PDF_BYTES, "application/pdf") + key = derive_doc_key(content) + assert key.startswith("doc_") + assert len(key) == 12 # "doc_" + 8 hex chars + + +class TestSessionState: + async def test_documents_persist_across_turns( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = {} + session = AgentSession() + + # Turn 1: upload + msg1 = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + ctx1 = _make_context([msg1]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=ctx1, state=state) + + assert "doc.pdf" in state["documents"] + + # Turn 2: follow-up (no file) + msg2 = Message(role="user", contents=[Content.from_text("What's the total?")]) + ctx2 = _make_context([msg2]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=ctx2, state=state) + + # Document should still be there + assert "doc.pdf" in state["documents"] + assert state["documents"]["doc.pdf"]["status"] == DocumentStatus.READY + + +class TestListDocumentsTool: + async def test_returns_all_docs_with_status( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = {} + session = AgentSession() + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "test.pdf"), + ], + ) + context = _make_context([msg]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Find the list_documents tool + list_tool = None + for tool in context.tools: + if getattr(tool, "name", None) == "list_documents": + list_tool = tool + break + + assert list_tool is not None + result = list_tool.func() # type: ignore[union-attr] + parsed = json.loads(result) + assert len(parsed) == 1 + assert parsed[0]["name"] == "test.pdf" + assert parsed[0]["status"] == DocumentStatus.READY + + +class TestOutputFiltering: + def test_default_markdown_and_fields(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(pdf_analysis_result) + + assert "markdown" in result + assert "fields" in result + assert "Contoso" in str(result["markdown"]) + + def test_markdown_only(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider(output_sections=["markdown"]) + result = provider._extract_sections(pdf_analysis_result) + + assert "markdown" in result + assert "fields" not in result + + def test_fields_only(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider(output_sections=["fields"]) + result = provider._extract_sections(invoice_analysis_result) + + assert "markdown" not in result + assert "fields" in result + fields = result["fields"] + assert isinstance(fields, dict) + assert "VendorName" in fields + + def test_field_values_extracted(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + + fields = result.get("fields") + assert isinstance(fields, dict) + assert "VendorName" in fields + assert fields["VendorName"]["value"] is not None + assert fields["VendorName"]["confidence"] is not None + + def test_invoice_field_extraction_matches_expected(self, invoice_analysis_result: AnalysisResult) -> None: + """Full invoice field extraction should match expected JSON structure. + + This test defines the complete expected output for all fields in the + invoice fixture, making it easy to review the extraction behavior at + a glance. Confidence is only present when the CU service provides it. + """ + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + fields = result.get("fields") + + expected_fields = { + "VendorName": { + "type": "string", + "value": "TechServe Global Partners", + "confidence": 0.71, + }, + "DueDate": { + "type": "date", + # SDK .value returns datetime.date for date fields + "value": fields["DueDate"]["value"], # dynamic — date object + "confidence": 0.793, + }, + "InvoiceDate": { + "type": "date", + "value": fields["InvoiceDate"]["value"], + "confidence": 0.693, + }, + "InvoiceId": { + "type": "string", + "value": "INV-100", + "confidence": 0.489, + }, + "AmountDue": { + "type": "object", + # No confidence — object types don't have it + "value": { + "Amount": {"type": "number", "value": 610.0, "confidence": 0.758}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + "SubtotalAmount": { + "type": "object", + "value": { + "Amount": {"type": "number", "value": 100.0, "confidence": 0.902}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + "LineItems": { + "type": "array", + "value": [ + { + "type": "object", + "value": { + "Description": {"type": "string", "value": "Consulting Services", "confidence": 0.664}, + "Quantity": {"type": "number", "value": 2.0, "confidence": 0.957}, + "UnitPrice": { + "type": "object", + "value": { + "Amount": {"type": "number", "value": 30.0, "confidence": 0.956}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + }, + }, + { + "type": "object", + "value": { + "Description": {"type": "string", "value": "Document Fee", "confidence": 0.712}, + "Quantity": {"type": "number", "value": 3.0, "confidence": 0.939}, + }, + }, + ], + }, + } + + assert fields == expected_fields + + +class TestDuplicateDocumentKey: + async def test_duplicate_filename_rejected( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Uploading the same filename twice in the same session should reject the second.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + # Turn 1: upload invoice.pdf + msg1 = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "invoice.pdf"), + ], + ) + context1 = _make_context([msg1]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context1, state=state) + assert "invoice.pdf" in state["documents"] + assert state["documents"]["invoice.pdf"]["status"] == DocumentStatus.READY + + # Turn 2: upload invoice.pdf again (different content but same filename) + msg2 = Message( + role="user", + contents=[ + Content.from_text("Analyze this too"), + _make_content_from_data(b"different-content", "application/pdf", "invoice.pdf"), + ], + ) + context2 = _make_context([msg2]) + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context2, state=state) + + # Should still have only one document, not re-analyzed + assert mock_cu_client.begin_analyze_binary.call_count == 1 + # Context messages should mention duplicate + assert any("already uploaded" in m.text for msgs in context2.context_messages.values() for m in msgs) + + async def test_duplicate_in_same_turn_rejected( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Two files with the same filename in the same turn: first wins, second rejected.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze both"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + _make_content_from_data(b"other-content", "application/pdf", "report.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Only analyzed once (first one wins) + assert mock_cu_client.begin_analyze_binary.call_count == 1 + assert "report.pdf" in state["documents"] + assert any("already uploaded" in m.text for msgs in context.context_messages.values() for m in msgs) + + +class TestBinaryStripping: + async def test_supported_files_stripped( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in here?"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # PDF should be stripped; text should remain + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/pdf" + assert any(c.text and "What's in here?" in c.text for c in m.contents) + + async def test_unsupported_files_left_in_place(self, mock_cu_client: AsyncMock) -> None: + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this zip?"), + Content.from_data( + b"PK\x03\x04fake", + "application/zip", + additional_properties={"filename": "archive.zip"}, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Zip should NOT be stripped (unsupported) + found_zip = False + for m in context.input_messages: + for c in m.contents: + if c.media_type == "application/zip": + found_zip = True + assert found_zip + + +# Real magic-byte headers for binary sniffing tests +_MP4_MAGIC = b"\x00\x00\x00\x1cftypisom" + b"\x00" * 250 +_WAV_MAGIC = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 250 +_MP3_MAGIC = b"ID3\x04\x00\x00" + b"\x00" * 250 +_FLAC_MAGIC = b"fLaC\x00\x00\x00\x00" + b"\x00" * 250 +_OGG_MAGIC = b"OggS\x00\x02" + b"\x00" * 250 +_AVI_MAGIC = b"RIFF\x00\x00\x00\x00AVI " + b"\x00" * 250 +_MOV_MAGIC = b"\x00\x00\x00\x14ftypqt " + b"\x00" * 250 + + +class TestMimeSniffing: + """Tests for binary MIME sniffing via filetype when upstream MIME is unreliable.""" + + async def test_octet_stream_mp4_detected_and_stripped( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP4 uploaded as application/octet-stream should be sniffed, corrected, and stripped.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this file?"), + _make_content_from_data(_MP4_MAGIC, "application/octet-stream", "video.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # MP4 should be stripped from input + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/octet-stream", "octet-stream content should be stripped" + + # CU should have been called + assert mock_cu_client.begin_analyze_binary.called + + async def test_octet_stream_wav_detected_via_sniff( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """WAV uploaded as application/octet-stream should be detected via filetype sniffing.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_WAV_MAGIC, "application/octet-stream", "audio.wav"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Should be detected and analyzed + assert "audio.wav" in state["documents"] + # The media_type should be corrected to audio/wav (via _MIME_ALIASES) + assert state["documents"]["audio.wav"]["media_type"] == "audio/wav" + + async def test_octet_stream_mp3_detected_via_sniff( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP3 uploaded as application/octet-stream should be detected as audio/mpeg.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_MP3_MAGIC, "application/octet-stream", "song.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "song.mp3" in state["documents"] + assert state["documents"]["song.mp3"]["media_type"] == "audio/mpeg" + + async def test_octet_stream_flac_alias_normalized( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """FLAC sniffed as audio/x-flac should be normalized to audio/flac.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_FLAC_MAGIC, "application/octet-stream", "music.flac"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "music.flac" in state["documents"] + assert state["documents"]["music.flac"]["media_type"] == "audio/flac" + + async def test_octet_stream_unknown_binary_not_stripped( + self, + mock_cu_client: AsyncMock, + ) -> None: + """Unknown binary with application/octet-stream should NOT be stripped.""" + provider = _make_provider(mock_client=mock_cu_client) + + unknown_bytes = b"\x00\x01\x02\x03random garbage" + b"\x00" * 250 + msg = Message( + role="user", + contents=[ + Content.from_text("What is this?"), + _make_content_from_data(unknown_bytes, "application/octet-stream", "mystery.bin"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Unknown file should NOT be stripped + found_octet = False + for m in context.input_messages: + for c in m.contents: + if c.media_type == "application/octet-stream": + found_octet = True + assert found_octet + + async def test_missing_mime_falls_back_to_filename( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Content with empty MIME but a .mp4 filename should be detected via mimetypes fallback.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + # Use garbage binary (filetype won't detect) but filename has .mp4 + garbage = b"\x00" * 300 + content = Content.from_data(garbage, "", additional_properties={"filename": "recording.mp4"}) + msg = Message( + role="user", + contents=[Content.from_text("Analyze"), content], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Should be detected via filename and analyzed + assert "recording.mp4" in state["documents"] + + async def test_correct_mime_not_sniffed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Files with correct MIME type should go through fast path without sniffing.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "doc.pdf" in state["documents"] + assert state["documents"]["doc.pdf"]["media_type"] == "application/pdf" + + async def test_sniffed_video_uses_correct_analyzer( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP4 sniffed from octet-stream should use prebuilt-videoSearch analyzer.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) # analyzer_id=None → auto-detect + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this video?"), + _make_content_from_data(_MP4_MAGIC, "application/octet-stream", "demo.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["demo.mp4"]["analyzer_id"] == "prebuilt-videoSearch" + + +class TestErrorHandling: + async def test_cu_service_error(self, mock_cu_client: AsyncMock) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_failing_poller(RuntimeError("Service unavailable")) + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "error.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["error.pdf"]["status"] == DocumentStatus.FAILED + assert "Service unavailable" in (state["documents"]["error.pdf"]["error"] or "") + + async def test_lazy_initialization_on_before_run(self) -> None: + """before_run works with eagerly-initialized client.""" + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider._client is not None + + mock_client = AsyncMock() + mock_client.begin_analyze_binary = AsyncMock( + side_effect=Exception("mock error"), + ) + provider._client = mock_client # type: ignore[assignment] + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + # Client should still be set + assert provider._client is not None + + +class TestMultiModalFixtures: + def test_pdf_fixture_loads(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(pdf_analysis_result) + assert "markdown" in result + assert "Contoso" in str(result["markdown"]) + + def test_audio_fixture_loads(self, audio_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(audio_analysis_result) + assert "markdown" in result + assert "Call Center" in str(result["markdown"]) + + def test_video_fixture_loads(self, video_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(video_analysis_result) + assert "markdown" in result + # All 3 segments should be concatenated at top level (for file_search) + md = str(result["markdown"]) + assert "Contoso Product Demo" in md + assert "real-time monitoring" in md + assert "contoso.com/cloud-manager" in md + # Duration should span all segments: (42000 - 1000) / 1000 = 41.0 + assert result.get("duration_seconds") == 41.0 + # kind from first segment + assert result.get("kind") == "audioVisual" + # resolution from first segment + assert result.get("resolution") == "640x480" + # Multi-segment: fields should be in per-segment list, not merged at top level + assert "fields" not in result # no top-level fields for multi-segment + segments = result.get("segments") + assert isinstance(segments, list) + assert len(segments) == 3 + # Each segment should have its own fields and time range + seg0 = segments[0] + assert "fields" in seg0 + assert "Summary" in seg0["fields"] + assert seg0.get("start_time_s") == 1.0 + assert seg0.get("end_time_s") == 14.0 + seg2 = segments[2] + assert "fields" in seg2 + assert "Summary" in seg2["fields"] + assert seg2.get("start_time_s") == 36.0 + assert seg2.get("end_time_s") == 42.0 + + def test_image_fixture_loads(self, image_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(image_analysis_result) + assert "markdown" in result + + def test_invoice_fixture_loads(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + assert "markdown" in result + assert "fields" in result + fields = result["fields"] + assert isinstance(fields, dict) + assert "VendorName" in fields + # Single-segment: should NOT have segments key + assert "segments" not in result + + +class TestFormatResult: + def test_format_includes_markdown_and_fields(self) -> None: + result: dict[str, object] = { + "markdown": "# Hello World", + "fields": {"Name": {"type": "string", "value": "Test", "confidence": 0.9}}, + } + formatted = format_result("test.pdf", result) + + assert 'Document analysis of "test.pdf"' in formatted + assert "# Hello World" in formatted + assert "Extracted Fields" in formatted + assert '"Name"' in formatted + + def test_format_markdown_only(self) -> None: + result: dict[str, object] = {"markdown": "# Just Text"} + formatted = format_result("doc.pdf", result) + + assert "# Just Text" in formatted + assert "Extracted Fields" not in formatted + + def test_format_multi_segment_video(self) -> None: + """Multi-segment results should format each segment with its own content + fields.""" + result: dict[str, object] = { + "kind": "audioVisual", + "duration_seconds": 41.0, + "resolution": "640x480", + "markdown": "scene1\n\n---\n\nscene2", # concatenated for file_search + "segments": [ + { + "start_time_s": 1.0, + "end_time_s": 14.0, + "markdown": "Welcome to the Contoso demo.", + "fields": { + "Summary": {"type": "string", "value": "Product intro"}, + "Speakers": { + "type": "object", + "value": {"count": 1, "names": ["Host"]}, + }, + }, + }, + { + "start_time_s": 15.0, + "end_time_s": 31.0, + "markdown": "Here we show real-time monitoring.", + "fields": { + "Summary": {"type": "string", "value": "Feature walkthrough"}, + "Speakers": { + "type": "object", + "value": {"count": 2, "names": ["Host", "Engineer"]}, + }, + }, + }, + ], + } + formatted = format_result("demo.mp4", result) + + expected = ( + 'Video analysis of "demo.mp4":\n' + "Duration: 0:41 | Resolution: 640x480\n" + "\n### Segment 1 (0:01 - 0:14)\n" + "\n```markdown\nWelcome to the Contoso demo.\n```\n" + "\n**Fields:**\n```json\n" + "{\n" + ' "Summary": {\n' + ' "type": "string",\n' + ' "value": "Product intro"\n' + " },\n" + ' "Speakers": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "count": 1,\n' + ' "names": [\n' + ' "Host"\n' + " ]\n" + " }\n" + " }\n" + "}\n```\n" + "\n### Segment 2 (0:15 - 0:31)\n" + "\n```markdown\nHere we show real-time monitoring.\n```\n" + "\n**Fields:**\n```json\n" + "{\n" + ' "Summary": {\n' + ' "type": "string",\n' + ' "value": "Feature walkthrough"\n' + " },\n" + ' "Speakers": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "count": 2,\n' + ' "names": [\n' + ' "Host",\n' + ' "Engineer"\n' + " ]\n" + " }\n" + " }\n" + "}\n```" + ) + assert formatted == expected + + # Verify ordering: segment 1 markdown+fields appear before segment 2 + seg1_pos = formatted.index("Segment 1") + seg2_pos = formatted.index("Segment 2") + contoso_pos = formatted.index("Welcome to the Contoso demo.") + monitoring_pos = formatted.index("Here we show real-time monitoring.") + intro_pos = formatted.index("Product intro") + walkthrough_pos = formatted.index("Feature walkthrough") + host_only_pos = formatted.index('"count": 1') + host_engineer_pos = formatted.index('"count": 2') + assert ( + seg1_pos + < contoso_pos + < intro_pos + < host_only_pos + < seg2_pos + < monitoring_pos + < walkthrough_pos + < host_engineer_pos + ) + + def test_format_single_segment_no_segments_key(self) -> None: + """Single-segment results should NOT have segments key — flat format.""" + result: dict[str, object] = { + "kind": "document", + "markdown": "# Invoice content", + "fields": { + "VendorName": {"type": "string", "value": "Contoso", "confidence": 0.95}, + "ShippingAddress": { + "type": "object", + "value": {"street": "123 Main St", "city": "Redmond", "state": "WA"}, + "confidence": 0.88, + }, + }, + } + formatted = format_result("invoice.pdf", result) + + expected = ( + 'Document analysis of "invoice.pdf":\n' + "\n## Content\n\n" + "```markdown\n# Invoice content\n```\n" + "\n## Extracted Fields\n\n" + "```json\n" + "{\n" + ' "VendorName": {\n' + ' "type": "string",\n' + ' "value": "Contoso",\n' + ' "confidence": 0.95\n' + " },\n" + ' "ShippingAddress": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "street": "123 Main St",\n' + ' "city": "Redmond",\n' + ' "state": "WA"\n' + " },\n" + ' "confidence": 0.88\n' + " }\n" + "}\n" + "```" + ) + assert formatted == expected + + # Verify ordering: header → markdown content → fields + header_pos = formatted.index('Document analysis of "invoice.pdf"') + content_header_pos = formatted.index("## Content") + markdown_pos = formatted.index("# Invoice content") + fields_header_pos = formatted.index("## Extracted Fields") + vendor_pos = formatted.index("Contoso") + address_pos = formatted.index("ShippingAddress") + street_pos = formatted.index("123 Main St") + assert ( + header_pos < content_header_pos < markdown_pos < fields_header_pos < vendor_pos < address_pos < street_pos + ) + + +class TestSupportedMediaTypes: + def test_pdf_supported(self) -> None: + assert "application/pdf" in SUPPORTED_MEDIA_TYPES + + def test_audio_supported(self) -> None: + assert "audio/mp3" in SUPPORTED_MEDIA_TYPES + assert "audio/wav" in SUPPORTED_MEDIA_TYPES + + def test_video_supported(self) -> None: + assert "video/mp4" in SUPPORTED_MEDIA_TYPES + + def test_zip_not_supported(self) -> None: + assert "application/zip" not in SUPPORTED_MEDIA_TYPES + + +class TestAnalyzerAutoDetection: + """Verify _resolve_analyzer_id auto-selects the right analyzer by media type.""" + + def test_explicit_analyzer_always_wins(self) -> None: + provider = _make_provider(analyzer_id="prebuilt-invoice") + assert provider._resolve_analyzer_id("audio/mp3") == "prebuilt-invoice" + assert provider._resolve_analyzer_id("video/mp4") == "prebuilt-invoice" + assert provider._resolve_analyzer_id("application/pdf") == "prebuilt-invoice" + + def test_auto_detect_pdf(self) -> None: + provider = _make_provider() # analyzer_id=None + assert provider._resolve_analyzer_id("application/pdf") == "prebuilt-documentSearch" + + def test_auto_detect_image(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("image/jpeg") == "prebuilt-documentSearch" + assert provider._resolve_analyzer_id("image/png") == "prebuilt-documentSearch" + + def test_auto_detect_audio(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("audio/mp3") == "prebuilt-audioSearch" + assert provider._resolve_analyzer_id("audio/wav") == "prebuilt-audioSearch" + assert provider._resolve_analyzer_id("audio/mpeg") == "prebuilt-audioSearch" + + def test_auto_detect_video(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("video/mp4") == "prebuilt-videoSearch" + assert provider._resolve_analyzer_id("video/webm") == "prebuilt-videoSearch" + + def test_auto_detect_unknown_falls_back_to_document(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("application/octet-stream") == "prebuilt-documentSearch" + + +class TestFileSearchIntegration: + _FILE_SEARCH_TOOL = {"type": "file_search", "vector_store_ids": ["vs_test123"]} + + def _make_mock_backend(self) -> AsyncMock: + """Create a mock FileSearchBackend.""" + backend = AsyncMock() + backend.upload_file = AsyncMock(return_value="file_test456") + backend.delete_file = AsyncMock() + return backend + + def _make_file_search_config(self, backend: AsyncMock | None = None) -> Any: + from agent_framework_azure_contentunderstanding import FileSearchConfig + + return FileSearchConfig( + backend=backend or self._make_mock_backend(), + vector_store_id="vs_test123", + file_search_tool=self._FILE_SEARCH_TOOL, + ) + + async def test_file_search_uploads_to_vector_store( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # File should be uploaded via backend + mock_backend.upload_file.assert_called_once() + call_args = mock_backend.upload_file.call_args + assert call_args[0][0] == "vs_test123" # vector_store_id + assert call_args[0][1] == "doc.pdf.md" # filename + # file_search tool should be registered on context + assert self._FILE_SEARCH_TOOL in context.tools + + async def test_file_search_no_content_injection( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """When file_search is enabled, full content should NOT be injected into context.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=self._make_file_search_config(), + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Context messages should NOT contain full document content + # (file_search handles retrieval instead) + for msgs in context.context_messages.values(): + for m in msgs: + assert "Document Content" not in m.text + + async def test_cleanup_deletes_uploaded_files( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Close should clean up uploaded files (not the vector store itself) + await provider.close() + mock_backend.delete_file.assert_called_once_with("file_test456") + + async def test_no_file_search_injects_content( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Without file_search, full content should be injected (default behavior).""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Without file_search, content SHOULD be injected + found_content = False + for msgs in context.context_messages.values(): + for m in msgs: + if "Document Content" in m.text or "Contoso" in m.text: + found_content = True + assert found_content + + async def test_file_search_multiple_files( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + audio_analysis_result: AnalysisResult, + ) -> None: + """Multiple files should each be uploaded to the vector store.""" + mock_backend = self._make_mock_backend() + # Return different file IDs for each upload + mock_backend.upload_file = AsyncMock(side_effect=["file_001", "file_002"]) + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + side_effect=[ + _make_mock_poller(pdf_analysis_result), + _make_mock_poller(audio_analysis_result), + ], + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Compare these"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + _make_content_from_data(b"\x00audio-fake", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Two files uploaded via backend + assert mock_backend.upload_file.call_count == 2 + + async def test_file_search_skips_empty_markdown( + self, + mock_cu_client: AsyncMock, + ) -> None: + """Upload should be skipped when CU returns no markdown content.""" + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + + # Create a result with empty markdown + empty_result = AnalysisResult({"contents": [{"markdown": "", "fields": {}}]}) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(empty_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "empty.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # No file should be uploaded (empty markdown) + mock_backend.upload_file.assert_not_called() + + async def test_pending_resolution_uploads_to_vector_store( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """When a background task completes in file_search mode, content should be + uploaded to the vector store — NOT injected into context messages.""" + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + # Simulate a completed background analysis via continuation token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + + state: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_fs", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Is the report ready?")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Document should be ready + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + # Content should NOT be injected into context messages + for msgs in context.context_messages.values(): + for m in msgs: + assert "Document Content" not in m.text + + # Should be uploaded to vector store via backend + mock_backend.upload_file.assert_called_once() + + # Context messages should mention file_search, not "provided above" + all_msg_text = " ".join(m.text for msgs in context.context_messages.values() for m in msgs) + assert "file_search" in all_msg_text or any("file_search" in instr for instr in context.instructions) + assert "provided above" not in all_msg_text + + +class TestCloseCancel: + async def test_close_cleans_up(self) -> None: + """close() should close the CU client.""" + provider = _make_provider(mock_client=AsyncMock()) + + await provider.close() + + # Client should be closed (no tasks to cancel — tokens are just strings) + provider._client.close.assert_called_once() + + +class TestSessionIsolation: + """Verify that per-session state (pending tasks, uploads) is isolated between sessions.""" + + async def test_background_task_isolated_per_session( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """A background task from session A must not leak into session B.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_slow_poller(pdf_analysis_result, delay=10.0)) + provider = _make_provider(mock_client=mock_cu_client, max_wait=0.1) + + # Session A: upload a file that times out → defers to background + msg_a = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + ], + ) + state_a: dict[str, Any] = {} + context_a = _make_context([msg_a]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_a, state=state_a) + + # Session A should have a pending token + assert "report.pdf" in state_a.get("_pending_tokens", {}) + + # Session B: separate state, no pending tokens + state_b: dict[str, Any] = {} + msg_b = Message(role="user", contents=[Content.from_text("Hello")]) + context_b = _make_context([msg_b]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_b, state=state_b) + + # Session B must NOT see session A's pending token + assert "_pending_tokens" not in state_b or "report.pdf" not in state_b.get("_pending_tokens", {}) + # Session B must NOT have session A's documents + assert "report.pdf" not in state_b.get("documents", {}) + + async def test_completed_task_resolves_in_correct_session( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """A completed background task should only inject content into its own session.""" + provider = _make_provider(mock_client=mock_cu_client) + + # Simulate completed analysis in session A via continuation token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + + state_a: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_a", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + state_b: dict[str, Any] = {} + + # Run session A — should resolve the task + context_a = _make_context([Message(role="user", contents=[Content.from_text("Is it ready?")])]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_a, state=state_a) + assert state_a["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + # Run session B — must NOT have any documents or resolved content + context_b = _make_context([Message(role="user", contents=[Content.from_text("Hello")])]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_b, state=state_b) + assert "report.pdf" not in state_b.get("documents", {}) + # Session B context should have no document-related messages + assert not any("report.pdf" in m.text for msgs in context_b.context_messages.values() for m in msgs) + + +class TestAnalyzerAutoDetectionE2E: + """End-to-end: verify _analyze_file stores the resolved analyzer in DocumentEntry.""" + + async def test_audio_file_uses_audio_analyzer( + self, + mock_cu_client: AsyncMock, + audio_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(audio_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) # analyzer_id=None + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe this"), + _make_content_from_data(b"\x00audio", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["call.mp3"]["analyzer_id"] == "prebuilt-audioSearch" + # CU client should have been called with the audio analyzer + mock_cu_client.begin_analyze_binary.assert_called_once() + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-audioSearch" + + async def test_video_file_uses_video_analyzer( + self, + mock_cu_client: AsyncMock, + video_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(video_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this video"), + _make_content_from_data(b"\x00video", "video/mp4", "demo.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["demo.mp4"]["analyzer_id"] == "prebuilt-videoSearch" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-videoSearch" + + async def test_pdf_file_uses_document_analyzer( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Read this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["report.pdf"]["analyzer_id"] == "prebuilt-documentSearch" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-documentSearch" + + async def test_explicit_override_ignores_media_type( + self, + mock_cu_client: AsyncMock, + audio_analysis_result: AnalysisResult, + ) -> None: + """Explicit analyzer_id should override auto-detection even for audio.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(audio_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client, analyzer_id="prebuilt-invoice") + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze"), + _make_content_from_data(b"\x00audio", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["call.mp3"]["analyzer_id"] == "prebuilt-invoice" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-invoice" + + async def test_per_file_analyzer_overrides_provider_default( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Per-file analyzer_id in additional_properties overrides provider-level default.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + # Provider default is prebuilt-documentSearch + provider = _make_provider( + mock_client=mock_cu_client, + analyzer_id="prebuilt-documentSearch", + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Process this invoice"), + Content.from_data( + _SAMPLE_PDF_BYTES, + "application/pdf", + # Per-file override to prebuilt-invoice + additional_properties={ + "filename": "invoice.pdf", + "analyzer_id": "prebuilt-invoice", + }, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Per-file override should win + assert state["documents"]["invoice.pdf"]["analyzer_id"] == "prebuilt-invoice" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-invoice" + + +class TestWarningsExtraction: + """Verify that CU analysis warnings are included in extracted output.""" + + def test_warnings_included_when_present(self) -> None: + """Non-empty warnings list should appear with code/message/target (RAI warnings).""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "markdown": "Some content", + "kind": "document", + } + ], + "warnings": [ + { + "code": "ContentFiltered", + "message": "Content was filtered due to Responsible AI policy.", + "target": "contents/0/markdown", + }, + { + "code": "ContentFiltered", + "message": "Violence content detected and filtered.", + }, + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + assert "warnings" in extracted + warnings = extracted["warnings"] + assert isinstance(warnings, list) + assert len(warnings) == 2 + # First warning has code + message + target + assert warnings[0]["code"] == "ContentFiltered" + assert warnings[0]["message"] == "Content was filtered due to Responsible AI policy." + assert warnings[0]["target"] == "contents/0/markdown" + # Second warning has code + message but no target + assert warnings[1]["code"] == "ContentFiltered" + assert warnings[1]["message"] == "Violence content detected and filtered." + assert "target" not in warnings[1] + + def test_warnings_omitted_when_empty(self, pdf_analysis_result: AnalysisResult) -> None: + """Empty/None warnings should not appear in extracted result.""" + provider = _make_provider() + extracted = provider._extract_sections(pdf_analysis_result) + assert "warnings" not in extracted + + +class TestCategoryExtraction: + """Verify that content-level category is included in extracted output.""" + + def test_category_included_single_segment(self) -> None: + """Category from classifier analyzer should appear in single-segment output.""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "markdown": "Contract text...", + "kind": "document", + "category": "Legal Contract", + } + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + assert extracted.get("category") == "Legal Contract" + + def test_category_in_multi_segment_video(self) -> None: + """Each segment should carry its own category in multi-segment output.""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "kind": "audioVisual", + "startTimeMs": 0, + "endTimeMs": 30000, + "markdown": "Opening scene with product showcase.", + "category": "ProductDemo", + "fields": { + "Summary": { + "type": "string", + "valueString": "Product demo intro", + } + }, + }, + { + "path": "input1", + "kind": "audioVisual", + "startTimeMs": 30000, + "endTimeMs": 60000, + "markdown": "Customer testimonial segment.", + "category": "Testimonial", + "fields": { + "Summary": { + "type": "string", + "valueString": "Customer feedback", + } + }, + }, + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + + # Top-level metadata + assert extracted["kind"] == "audioVisual" + assert extracted["duration_seconds"] == 60.0 + + # Segments should have per-segment category + segments = extracted["segments"] + assert isinstance(segments, list) + assert len(segments) == 2 + + # First segment: ProductDemo + assert segments[0]["category"] == "ProductDemo" + assert segments[0]["start_time_s"] == 0.0 + assert segments[0]["end_time_s"] == 30.0 + assert segments[0]["markdown"] == "Opening scene with product showcase." + assert "Summary" in segments[0]["fields"] + + # Second segment: Testimonial + assert segments[1]["category"] == "Testimonial" + assert segments[1]["start_time_s"] == 30.0 + assert segments[1]["end_time_s"] == 60.0 + assert segments[1]["markdown"] == "Customer testimonial segment." + + # Top-level concatenated markdown for file_search + assert "Opening scene" in extracted["markdown"] + assert "Customer testimonial" in extracted["markdown"] + + def test_category_omitted_when_none(self, pdf_analysis_result: AnalysisResult) -> None: + """No category should be in output when analyzer doesn't classify.""" + provider = _make_provider() + extracted = provider._extract_sections(pdf_analysis_result) + assert "category" not in extracted + + +class TestContentRangeSupport: + """Verify that content_range from additional_properties is passed to CU.""" + + async def test_content_range_passed_to_begin_analyze( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """content_range in additional_properties should be forwarded to AnalysisInput.""" + from azure.ai.contentunderstanding.models import AnalysisInput + + mock_cu_client.begin_analyze = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze pages 1-3"), + Content.from_uri( + "https://example.com/report.pdf", + media_type="application/pdf", + additional_properties={"filename": "report.pdf", "content_range": "1-3"}, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Verify begin_analyze was called with AnalysisInput containing content_range + mock_cu_client.begin_analyze.assert_called_once() + call_kwargs = mock_cu_client.begin_analyze.call_args + inputs_arg = call_kwargs.kwargs.get("inputs") or call_kwargs[1].get("inputs") + assert inputs_arg is not None + assert len(inputs_arg) == 1 + assert isinstance(inputs_arg[0], AnalysisInput) + assert inputs_arg[0].content_range == "1-3" + assert inputs_arg[0].url == "https://example.com/report.pdf" diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_integration.py b/python/packages/azure-contentunderstanding/tests/cu/test_integration.py new file mode 100644 index 0000000000..0e204e2507 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_integration.py @@ -0,0 +1,312 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Integration tests for ContentUnderstandingContextProvider. + +These tests require a live Azure Content Understanding endpoint. +Set AZURE_CONTENTUNDERSTANDING_ENDPOINT to enable them. + +To generate fixtures for unit tests, run these tests with --update-fixtures flag +and the resulting JSON files will be written to tests/cu/fixtures/. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +skip_if_cu_integration_tests_disabled = pytest.mark.skipif( + not os.environ.get("AZURE_CONTENTUNDERSTANDING_ENDPOINT"), + reason="CU integration tests disabled (AZURE_CONTENTUNDERSTANDING_ENDPOINT not set)", +) + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + +# Shared sample asset — same PDF used by samples and integration tests +INVOICE_PDF_PATH = Path(__file__).resolve().parents[2] / "samples" / "shared" / "sample_assets" / "invoice.pdf" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_cu_integration_tests_disabled +async def test_analyze_pdf_binary() -> None: + """Analyze a PDF via binary upload and optionally capture fixture.""" + from azure.ai.contentunderstanding.aio import ContentUnderstandingClient + from azure.identity.aio import DefaultAzureCredential + + endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"] + analyzer_id = os.environ.get("AZURE_CONTENTUNDERSTANDING_ANALYZER_ID", "prebuilt-documentSearch") + + pdf_path = INVOICE_PDF_PATH + assert pdf_path.exists(), f"Test fixture not found: {pdf_path}" + pdf_bytes = pdf_path.read_bytes() + + async with DefaultAzureCredential() as credential, ContentUnderstandingClient(endpoint, credential) as client: + poller = await client.begin_analyze_binary( + analyzer_id, + binary_input=pdf_bytes, + content_type="application/pdf", + ) + result = await poller.result() + + assert result.contents + assert result.contents[0].markdown + assert len(result.contents[0].markdown) > 10 + assert "CONTOSO LTD." in result.contents[0].markdown + + # Optionally capture fixture + if os.environ.get("CU_UPDATE_FIXTURES"): + FIXTURES_DIR.mkdir(exist_ok=True) + fixture_path = FIXTURES_DIR / "analyze_pdf_result.json" + fixture_path.write_text(json.dumps(result.as_dict(), indent=2, default=str)) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_cu_integration_tests_disabled +async def test_before_run_e2e() -> None: + """End-to-end test: Content.from_data → before_run → state populated.""" + from agent_framework import Content, Message, SessionContext + from agent_framework._sessions import AgentSession + from azure.identity.aio import DefaultAzureCredential + + from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider + + endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"] + + pdf_path = INVOICE_PDF_PATH + assert pdf_path.exists(), f"Test fixture not found: {pdf_path}" + pdf_bytes = pdf_path.read_bytes() + + async with DefaultAzureCredential() as credential: + cu = ContentUnderstandingContextProvider( + endpoint=endpoint, + credential=credential, + max_wait=None, # wait until analysis completes (no background deferral) + ) + async with cu: + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this document?"), + Content.from_data( + pdf_bytes, + "application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + ], + ) + context = SessionContext(input_messages=[msg]) + state: dict[str, object] = {} + session = AgentSession() + + from unittest.mock import MagicMock + + await cu.before_run(agent=MagicMock(), session=session, context=context, state=state) + + docs = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice.pdf" in docs + doc_entry = docs["invoice.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +# Raw GitHub URL for a public invoice PDF from the CU samples repo +_INVOICE_PDF_URL = ( + "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf" +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_cu_integration_tests_disabled +async def test_before_run_uri_content() -> None: + """End-to-end test: Content.from_uri with an external URL → before_run → state populated. + + Verifies that CU can analyze a file referenced by URL (not base64 data). + Uses a public invoice PDF from the Azure CU samples repository. + """ + from agent_framework import Content, Message, SessionContext + from agent_framework._sessions import AgentSession + from azure.identity.aio import DefaultAzureCredential + + from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider + + endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"] + + async with DefaultAzureCredential() as credential: + cu = ContentUnderstandingContextProvider( + endpoint=endpoint, + credential=credential, + max_wait=None, # wait until analysis completes (no background deferral) + ) + async with cu: + msg = Message( + role="user", + contents=[ + Content.from_text("What's on this invoice?"), + Content.from_uri( + uri=_INVOICE_PDF_URL, + media_type="application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + ], + ) + context = SessionContext(input_messages=[msg]) + state: dict[str, object] = {} + session = AgentSession() + + from unittest.mock import MagicMock + + await cu.before_run(agent=MagicMock(), session=session, context=context, state=state) + + docs = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice.pdf" in docs + + doc_entry = docs["invoice.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_cu_integration_tests_disabled +async def test_before_run_data_uri_content() -> None: + """End-to-end test: Content.from_uri with a base64 data URI → before_run → state populated. + + Verifies that CU can analyze a file embedded as a data URI (data:application/pdf;base64,...). + This tests the data URI path: from_uri with "data:" prefix → type="data" → begin_analyze_binary. + """ + import base64 + + from agent_framework import Content, Message, SessionContext + from agent_framework._sessions import AgentSession + from azure.identity.aio import DefaultAzureCredential + + from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider + + endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"] + + pdf_path = INVOICE_PDF_PATH + assert pdf_path.exists(), f"Test fixture not found: {pdf_path}" + pdf_bytes = pdf_path.read_bytes() + b64 = base64.b64encode(pdf_bytes).decode("ascii") + data_uri = f"data:application/pdf;base64,{b64}" + + async with DefaultAzureCredential() as credential: + cu = ContentUnderstandingContextProvider( + endpoint=endpoint, + credential=credential, + max_wait=None, # wait until analysis completes + ) + async with cu: + msg = Message( + role="user", + contents=[ + Content.from_text("What's on this invoice?"), + Content.from_uri( + uri=data_uri, + media_type="application/pdf", + additional_properties={"filename": "invoice_b64.pdf"}, + ), + ], + ) + context = SessionContext(input_messages=[msg]) + state: dict[str, object] = {} + session = AgentSession() + + from unittest.mock import MagicMock + + await cu.before_run(agent=MagicMock(), session=session, context=context, state=state) + + docs = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice_b64.pdf" in docs + + doc_entry = docs["invoice_b64.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_cu_integration_tests_disabled +async def test_before_run_background_analysis() -> None: + """End-to-end test: max_wait timeout → background analysis → resolved on next turn. + + Uses a short max_wait (0.5s) so CU analysis is deferred to background. + Then waits for analysis to complete and calls before_run again to verify + the background task resolves and the document becomes ready. + """ + import asyncio + + from agent_framework import Content, Message, SessionContext + from agent_framework._sessions import AgentSession + from azure.identity.aio import DefaultAzureCredential + + from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider + + endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"] + + async with DefaultAzureCredential() as credential: + cu = ContentUnderstandingContextProvider( + endpoint=endpoint, + credential=credential, + max_wait=0.5, # short timeout to force background deferral + ) + async with cu: + # Turn 1: upload file — should time out and defer to background + msg = Message( + role="user", + contents=[ + Content.from_text("What's on this invoice?"), + Content.from_uri( + uri=_INVOICE_PDF_URL, + media_type="application/pdf", + additional_properties={"filename": "invoice.pdf"}, + ), + ], + ) + context = SessionContext(input_messages=[msg]) + state: dict[str, object] = {} + session = AgentSession() + + from unittest.mock import MagicMock + + await cu.before_run(agent=MagicMock(), session=session, context=context, state=state) + + docs = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice.pdf" in docs + assert docs["invoice.pdf"]["status"] == "analyzing", ( + f"Expected 'analyzing' but got '{docs['invoice.pdf']['status']}' — " + "CU responded too fast for the 0.5s timeout" + ) + assert docs["invoice.pdf"]["result"] is None + + # Wait for background analysis to complete + await asyncio.sleep(30) + + # Turn 2: no new files — should resolve the background task + msg2 = Message(role="user", contents=[Content.from_text("Is it ready?")]) + context2 = SessionContext(input_messages=[msg2]) + + await cu.before_run(agent=MagicMock(), session=session, context=context2, state=state) + + assert docs["invoice.pdf"]["status"] == "ready" + assert docs["invoice.pdf"]["result"] is not None + assert docs["invoice.pdf"]["result"].get("markdown") + assert "CONTOSO LTD." in docs["invoice.pdf"]["result"]["markdown"] diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_models.py b/python/packages/azure-contentunderstanding/tests/cu/test_models.py new file mode 100644 index 0000000000..484645f09a --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_models.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from agent_framework_azure_contentunderstanding._models import ( + DocumentEntry, + DocumentStatus, + FileSearchConfig, +) + + +class TestDocumentEntry: + def test_construction(self) -> None: + entry: DocumentEntry = { + "status": DocumentStatus.READY, + "filename": "invoice.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": "2026-01-01T00:00:00+00:00", + "analysis_duration_s": 1.23, + "upload_duration_s": None, + "result": {"markdown": "# Title"}, + "error": None, + } + assert entry["status"] == DocumentStatus.READY + assert entry["filename"] == "invoice.pdf" + assert entry["analyzer_id"] == "prebuilt-documentSearch" + assert entry["analysis_duration_s"] == 1.23 + assert entry["upload_duration_s"] is None + + def test_failed_entry(self) -> None: + entry: DocumentEntry = { + "status": DocumentStatus.FAILED, + "filename": "bad.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": "2026-01-01T00:00:00+00:00", + "analysis_duration_s": 0.5, + "upload_duration_s": None, + "result": None, + "error": "Service unavailable", + } + assert entry["status"] == DocumentStatus.FAILED + assert entry["error"] == "Service unavailable" + assert entry["result"] is None + + +class TestFileSearchConfig: + def test_required_fields(self) -> None: + backend = AsyncMock() + tool = {"type": "file_search", "vector_store_ids": ["vs_123"]} + config = FileSearchConfig(backend=backend, vector_store_id="vs_123", file_search_tool=tool) + assert config.backend is backend + assert config.vector_store_id == "vs_123" + assert config.file_search_tool is tool + + def test_from_openai_factory(self) -> None: + from agent_framework_azure_contentunderstanding._file_search import OpenAIFileSearchBackend + + client = AsyncMock() + tool = {"type": "file_search", "vector_store_ids": ["vs_abc"]} + config = FileSearchConfig.from_openai(client, vector_store_id="vs_abc", file_search_tool=tool) + assert isinstance(config.backend, OpenAIFileSearchBackend) + assert config.vector_store_id == "vs_abc" + assert config.file_search_tool is tool diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 79736b5ca7..38354f4c13 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -4,6 +4,7 @@ This module lazily re-exports objects from: - ``agent-framework-anthropic`` +- ``agent-framework-azure-contentunderstanding`` - ``agent-framework-foundry`` - ``agent-framework-foundry-local`` """ @@ -12,7 +13,12 @@ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnalysisSection": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "ContentUnderstandingContextProvider": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 87cc7a3bda..7f26d0305d 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -4,6 +4,13 @@ # Install the relevant packages for full type support. from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient +from agent_framework_azure_contentunderstanding import ( + AnalysisSection, + ContentUnderstandingContextProvider, + DocumentStatus, + FileSearchBackend, + FileSearchConfig, +) from agent_framework_foundry import ( FoundryAgent, FoundryChatClient, @@ -31,7 +38,12 @@ from agent_framework_foundry_local import ( ) __all__ = [ + "AnalysisSection", "AnthropicFoundryClient", + "ContentUnderstandingContextProvider", + "DocumentStatus", + "FileSearchBackend", + "FileSearchConfig", "FoundryAgent", "FoundryChatClient", "FoundryChatOptions", diff --git a/python/pyproject.toml b/python/pyproject.toml index b2ce2ea571..ef611bf4a5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -92,6 +92,7 @@ agent-framework-openai = { workspace = true } agent-framework-orchestrations = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } +agent-framework-azure-contentunderstanding = { workspace = true } litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" } [tool.ruff] @@ -190,6 +191,7 @@ executionEnvironments = [ { root = "packages/ag-ui/tests", reportPrivateUsage = "none" }, { root = "packages/anthropic/tests", reportPrivateUsage = "none" }, { root = "packages/azure-ai-search/tests", reportPrivateUsage = "none" }, + { root = "packages/azure-contentunderstanding/tests", reportPrivateUsage = "none" }, { root = "packages/azure-cosmos/tests", reportPrivateUsage = "none" }, { root = "packages/azurefunctions/tests", reportPrivateUsage = "none" }, { root = "packages/bedrock/tests", reportPrivateUsage = "none" }, diff --git a/python/uv.lock b/python/uv.lock index a8ee0ab7f5..4e60e5cfd6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ members = [ "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai-search", + "agent-framework-azure-contentunderstanding", "agent-framework-azure-cosmos", "agent-framework-azurefunctions", "agent-framework-bedrock", @@ -222,6 +223,25 @@ requires-dist = [ { name = "azure-search-documents", specifier = ">=11.7.0b2,<11.7.0b3" }, ] +[[package]] +name = "agent-framework-azure-contentunderstanding" +version = "1.0.0a260401" +source = { editable = "packages/azure-contentunderstanding" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "aiohttp", specifier = ">=3.9,<4" }, + { name = "azure-ai-contentunderstanding", specifier = ">=1.0.0,<1.1" }, + { name = "filetype", specifier = ">=1.2,<2" }, +] + [[package]] name = "agent-framework-azure-cosmos" version = "1.0.0b260428" @@ -1110,6 +1130,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" }, ] +[[package]] +name = "azure-ai-contentunderstanding" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/97/6696d3fecb5650213c4b29dd45a306cc1da954e70e168605a5d372c51c3e/azure_ai_contentunderstanding-1.0.1.tar.gz", hash = "sha256:f653ea85a73df7d377ab55e39d7f02e271c66765f5fa5a3a56b59798bcb01e2c", size = 214634, upload-time = "2026-03-10T02:01:20.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f4/bb26c5b347f18fc85a066b4360a93204466ef7026d28585f3bf77c1a73ed/azure_ai_contentunderstanding-1.0.1-py3-none-any.whl", hash = "sha256:8d34246482691229ef75fe25f18c066d5f6adfe03b638c47f9b784c2992e6611", size = 101275, upload-time = "2026-03-10T02:01:22.181Z" }, +] + [[package]] name = "azure-ai-inference" version = "1.0.0b9" @@ -2187,6 +2221,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "flask" version = "3.1.3" From 40e90c96c396011cf160f943c4daa2e83afc6989 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:53:19 -0700 Subject: [PATCH 2/3] .NET: Add HttpRequestAction support to declarative workflows (#5474) * Add declarative HttpRequestAction support to workflows * Clean up response body for diagnostics and fix tests. * Fix merge with main. * Remove redundant fallback for request content headers. --- .../DeclarativeWorkflowOptions.cs | 6 + .../DefaultHttpRequestHandler.cs | 289 +++++++ .../IHttpRequestHandler.cs | 103 +++ .../Interpreter/WorkflowActionVisitor.cs | 14 +- .../ObjectModel/HttpRequestExecutor.cs | 346 ++++++++ .../Framework/IntegrationTest.cs | 15 +- .../InvokeToolWorkflowTest.cs | 43 + .../Workflows/HttpRequest.yaml | 32 + .../DeclarativeWorkflowTest.cs | 24 +- .../DefaultHttpRequestHandlerTests.cs | 510 ++++++++++++ .../ObjectModel/HttpRequestExecutorTest.cs | 759 ++++++++++++++++++ .../Workflows/HttpRequest.yaml | 15 + 12 files changed, 2150 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 9e421832d4..90439402db 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public IMcpToolHandler? McpToolHandler { get; init; } + /// + /// Gets or sets the HTTP request handler for executing HttpRequestAction actions within workflows. + /// If not set, HTTP request actions will fail with an appropriate error message. + /// + public IHttpRequestHandler? HttpRequestHandler { get; init; } + /// /// Defines the configuration settings for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs new file mode 100644 index 0000000000..606a716c20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Default implementation of built on . +/// +/// +/// +/// This handler supports per-request authentication via an optional httpClientProvider callback that +/// returns a pre-configured for a given request (e.g. authenticated, custom handler). +/// When the provider returns , or no provider is supplied, a shared internal +/// is used. +/// +/// +/// The handler applies the per-request using a linked +/// so it does not mutate on shared instances. +/// +/// +public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable +{ + private readonly Func>? _httpClientProvider; + private readonly Lazy _ownedHttpClient; + + /// + /// Initializes a new instance of the class that uses an + /// internally owned for all requests. The internal client is disposed + /// when is called. + /// + public DefaultHttpRequestHandler() + : this(httpClientProvider: null) + { + } + + /// + /// Initializes a new instance of the class that uses the + /// supplied for all requests. + /// + /// + /// The to use for all requests. The caller retains ownership of this + /// instance; it is not disposed by . + /// + /// is . + public DefaultHttpRequestHandler(HttpClient httpClient) + : this(CreateSingleClientProvider(httpClient)) + { + } + + /// + /// Initializes a new instance of the class that selects + /// an per request via a caller-supplied callback — for example, to route + /// different URLs through differently authenticated clients. + /// + /// + /// An optional callback invoked for each request. The callback receives the + /// and should return a pre-configured (e.g. with authentication or a custom + /// transport). Return to fall back to the handler's shared internal + /// . + /// + /// + /// + /// Ownership: the caller is solely responsible for the lifetime of clients returned by this + /// callback. will not dispose provider-returned + /// clients; only the handler's internally owned fallback client is disposed by . + /// + /// + /// Reuse: callers are expected to cache and reuse clients (for example, keyed by base URL or + /// auth scope) across requests. Returning a newly allocated on every + /// invocation will leak sockets and handler resources. + /// + /// + public DefaultHttpRequestHandler(Func>? httpClientProvider) + { + this._httpClientProvider = httpClientProvider; + this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); + } + + private static Func> CreateSingleClientProvider(HttpClient httpClient) + { + if (httpClient is null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + + return (_, _) => Task.FromResult(httpClient); + } + + /// + public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Url)) + { + throw new ArgumentException("Request URL must be provided.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Method)) + { + throw new ArgumentException("Request method must be provided.", nameof(request)); + } + + HttpClient? providedClient = null; + if (this._httpClientProvider is not null) + { + providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); + } + + HttpClient client = providedClient ?? this._ownedHttpClient.Value; + + using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); + + using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + + timeoutCts?.CancelAfter(request.Timeout!.Value); + + CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; + + using HttpResponseMessage httpResponse = await client + .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) + .ConfigureAwait(false); + + string? body = httpResponse.Content is null + ? null +#if NET + : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); +#else + : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); + AppendHeaders(headers, httpResponse.Headers); + if (httpResponse.Content is not null) + { + AppendHeaders(headers, httpResponse.Content.Headers); + } + + return new HttpRequestResult + { + StatusCode = (int)httpResponse.StatusCode, + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + Body = body, + Headers = headers, + }; + } + + /// + public ValueTask DisposeAsync() + { + if (this._ownedHttpClient.IsValueCreated) + { + this._ownedHttpClient.Value.Dispose(); + } + + return default; + } + + private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request) + { + HttpMethod method = ResolveMethod(request.Method); + string requestUri = ResolveRequestUri(request); + HttpRequestMessage httpRequest = new(method, requestUri); + + if (request.Body is not null) + { + string contentType = string.IsNullOrWhiteSpace(request.BodyContentType) + ? "text/plain" + : request.BodyContentType!; + + httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); + // Replace the default content-type header (including charset) with the declared type. + httpRequest.Content.Headers.Remove("Content-Type"); + httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); + } + + if (request.Headers is not null) + { + foreach (KeyValuePair header in request.Headers) + { + if (string.IsNullOrEmpty(header.Key)) + { + continue; + } + + // Content-* headers belong on HttpContent; all others belong on the request. + if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null) + { + httpRequest.Content.Headers.Remove(header.Key); + httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + continue; + } + + if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + return httpRequest; + } + + private static HttpMethod ResolveMethod(string method) + { + string normalized = method.Trim().ToUpperInvariant(); + return normalized switch + { + "GET" => HttpMethod.Get, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, +#if NET + "PATCH" => HttpMethod.Patch, +#else + "PATCH" => new HttpMethod("PATCH"), +#endif + _ => new HttpMethod(normalized), + }; + } + + private static string ResolveRequestUri(HttpRequestInfo request) + { + string baseUrl = request.Url; + if (request.QueryParameters is null || request.QueryParameters.Count == 0) + { + return baseUrl; + } + + StringBuilder queryBuilder = new(); + foreach (KeyValuePair parameter in request.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key)) + { + continue; + } + + if (queryBuilder.Length > 0) + { + queryBuilder.Append('&'); + } + + queryBuilder.Append(Uri.EscapeDataString(parameter.Key)) + .Append('=') + .Append(Uri.EscapeDataString(parameter.Value ?? string.Empty)); + } + + if (queryBuilder.Length == 0) + { + return baseUrl; + } + + char separator = baseUrl.Contains('?') ? '&' : '?'; + return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString()); + } + + private static void AppendHeaders( + Dictionary> target, + System.Net.Http.Headers.HttpHeaders source) + { + foreach (KeyValuePair> header in source) + { + string[] values = header.Value.ToArray(); + + if (target.TryGetValue(header.Key, out IReadOnlyList? existing)) + { + List combined = new(existing); + combined.AddRange(values); + target[header.Key] = combined; + } + else + { + target[header.Key] = values; + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs new file mode 100644 index 0000000000..df80433d41 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Defines the contract for executing HTTP requests emitted by HttpRequestAction within declarative workflows. +/// +/// +/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations +/// for local development, hosted workflows, authenticated scenarios, and testing. +/// +public interface IHttpRequestHandler +{ + /// + /// Sends an HTTP request and returns the response. + /// + /// The HTTP request to send. + /// A token to observe cancellation. + /// The describing the HTTP response. + Task SendAsync( + HttpRequestInfo request, + CancellationToken cancellationToken = default); +} + +/// +/// Describes an HTTP request to be sent by an . +/// +[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")] +public sealed class HttpRequestInfo +{ + /// + /// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE). + /// + public string Method { get; init; } = "GET"; + + /// + /// Gets the absolute URL to send the request to. + /// + public string Url { get; init; } = string.Empty; + + /// + /// Gets the headers to include on the request, excluding the Content-Type header (which is supplied via ). + /// + public IReadOnlyDictionary? Headers { get; init; } + + /// + /// Gets the Content-Type of the request body, or if no body is sent. + /// + public string? BodyContentType { get; init; } + + /// + /// Gets the serialized request body, or if no body is sent. + /// + public string? Body { get; init; } + + /// + /// Gets the maximum amount of time to wait for the request to complete, or to use the handler default. + /// + public TimeSpan? Timeout { get; init; } + + /// + /// Gets the query parameters to append to the request URL, with values already formatted as strings. + /// + public IReadOnlyDictionary? QueryParameters { get; init; } + + /// + /// Gets the name of the declared remote connection, or if no connection is declared. + /// This maps to the Foundry project connection Id and is only used when running in foundry service. + /// + public string? ConnectionName { get; init; } +} + +/// +/// Represents the result of an HTTP request executed by an . +/// +public sealed class HttpRequestResult +{ + /// + /// Gets the HTTP status code returned by the server. + /// + public int StatusCode { get; init; } + + /// + /// Gets a value indicating whether the status code is in the range 200-299. + /// + public bool IsSuccessStatusCode { get; init; } + + /// + /// Gets the response body, or if no body was returned. + /// + public string? Body { get; init; } + + /// + /// Gets the response headers keyed by header name. Each header may have multiple values. + /// + public IReadOnlyDictionary>? Headers { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index fd818672dd..1cd1b2bc94 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -529,6 +529,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); } + protected override void Visit(HttpRequestAction item) + { + this.Trace(item); + + if (this._workflowOptions.HttpRequestHandler is null) + { + throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions."); + } + + this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState)); + } + #region Not supported protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); @@ -573,8 +585,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor protected override void Visit(GetConversationMembers item) => this.NotSupported(item); - protected override void Visit(HttpRequestAction item) => this.NotSupported(item); - protected override void Visit(RecognizeIntent item) => this.NotSupported(item); protected override void Visit(TransferConversation item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs new file mode 100644 index 0000000000..6bdddbf4e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +/// +/// Executor for the action. +/// Dispatches the request through the configured and assigns +/// the response body and headers to the declared property paths. +/// +internal sealed class HttpRequestExecutor( + HttpRequestAction model, + IHttpRequestHandler httpRequestHandler, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string method = this.GetMethod(); + string url = this.GetUrl(); + Dictionary? headers = this.GetHeaders(); + Dictionary? queryParameters = this.GetQueryParameters(); + (string? body, string? contentType) = this.GetBody(); + TimeSpan? timeout = this.GetTimeout(); + string? conversationId = this.GetConversationId(); + string? connectionName = this.GetConnectionName(); + + HttpRequestInfo requestInfo = new() + { + Method = method, + Url = url, + Headers = headers, + QueryParameters = queryParameters, + Body = body, + BodyContentType = contentType, + Timeout = timeout, + ConnectionName = connectionName, + }; + + HttpRequestResult result; + try + { + result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw this.Exception($"HTTP request to '{url}' timed out."); + } + catch (Exception exception) when (exception is not DeclarativeActionException) + { + throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception); + } + + if (result.IsSuccessStatusCode) + { + await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false); + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false); + return default; + } + + // Non-success status code - throw. + // Also publish response headers for diagnostic purposes. + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + + string bodyPreview = FormatBodyForDiagnostics(result.Body); + string message = bodyPreview.Length == 0 + ? $"HTTP request to '{url}' failed with status code {result.StatusCode}." + : $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'"; + + throw this.Exception(message); + } + + // Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages). + // Exception messages are often logged and persisted, so we clip the body to bound both exposure + // and message size. Full bodies are still available via the success path (assigned to Response). + private const int MaxBodyDiagnosticLength = 256; + private const string BodyTruncationSuffix = " \u2026 [truncated]"; + + private static string FormatBodyForDiagnostics(string? body) + { + if (string.IsNullOrEmpty(body)) + { + return string.Empty; + } + + int sourceLen = body!.Length; + bool truncated = sourceLen > MaxBodyDiagnosticLength; + int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen; + int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0); + + // Size the buffer for the final string so we only allocate once for the chars + // and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000. + char[] buffer = new char[finalLen]; + for (int i = 0; i < copyLen; i++) + { + char c = body[i]; + buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c; + } + + if (truncated) + { + BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length); + } + + return new string(buffer); + } + + private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken) + { + if (conversationId is null || string.IsNullOrEmpty(responseBody)) + { + return; + } + + ChatMessage message = new(ChatRole.Assistant, responseBody); + await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody) + { + if (this.Model.Response is not { Path: { } responsePath }) + { + return; + } + + await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false); + } + + private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary>? responseHeaders) + { + if (this.Model.ResponseHeaders is not { Path: { } headersPath }) + { + return; + } + + if (responseHeaders is null || responseHeaders.Count == 0) + { + await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + return; + } + + // Flatten multi-value headers by joining with commas (standard HTTP header folding). + Dictionary flattened = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair> header in responseHeaders) + { + flattened[header.Key] = string.Join(",", header.Value); + } + + await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false); + } + + private static FormulaValue ParseResponseBody(string? responseBody) + { + if (string.IsNullOrEmpty(responseBody)) + { + return FormulaValue.NewBlank(); + } + + // Attempt to parse as JSON so records/tables are exposed naturally to the workflow. + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(responseBody); + + object? parsedValue = jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType), + JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()), + JsonValueKind.String => jsonDocument.RootElement.GetString(), + JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) + ? l + : jsonDocument.RootElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => responseBody, + }; + + return parsedValue.ToFormula(); + } + catch (JsonException) + { + // Not valid JSON — return the raw string. + return FormulaValue.New(responseBody); + } + } + + private string GetMethod() + { + EnumExpression? methodExpression = this.Model.Method; + if (methodExpression is null) + { + return "GET"; + } + + HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value; + return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant(); + } + + private string GetUrl() => + this.Evaluator.GetValue( + Throw.IfNull( + this.Model.Url, + $"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value; + + private Dictionary? GetHeaders() + { + if (this.Model.Headers is null || this.Model.Headers.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair header in this.Model.Headers) + { + string value = this.Evaluator.GetValue(header.Value).Value; + if (!string.IsNullOrEmpty(value)) + { + result[header.Key] = value; + } + } + + return result.Count == 0 ? null : result; + } + + private (string? Body, string? ContentType) GetBody() + { + switch (this.Model.Body) + { + case null: + case NoRequestContent: + return (null, null); + + case JsonRequestContent jsonContent when jsonContent.Content is not null: + { + FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula(); + string json = formula.ToJson().ToJsonString(); + return (json, "application/json"); + } + + case RawRequestContent rawContent: + { + string? content = rawContent.Content is null + ? null + : this.Evaluator.GetValue(rawContent.Content).Value; + + string? contentType = rawContent.ContentType is null + ? null + : this.Evaluator.GetValue(rawContent.ContentType).Value; + + return (content, string.IsNullOrEmpty(contentType) ? null : contentType); + } + + default: + return (null, null); + } + } + + private TimeSpan? GetTimeout() + { + if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue) + { + return null; + } + + long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value; + return value > 0 ? TimeSpan.FromMilliseconds(value) : null; + } + + private Dictionary? GetQueryParameters() + { + if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair parameter in this.Model.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null) + { + continue; + } + + object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject(); + string? formatted = FormatQueryValue(rawValue); + if (formatted is not null) + { + result[parameter.Key] = formatted; + } + } + + return result.Count == 0 ? null : result; + } + + private static string? FormatQueryValue(object? value) => + value switch + { + null => null, + string s => s, + bool b => b ? "true" : "false", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private string? GetConversationId() + { + if (this.Model.ConversationId is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.ConversationId).Value; + return value.Length == 0 ? null : value; + } + + private string? GetConnectionName() + { + RemoteConnection? connection = this.Model.Connection; + if (connection is null) + { + return null; + } + + string? name = connection.Name is null + ? null + : this.Evaluator.GetValue(connection.Name).Value; + + return string.IsNullOrEmpty(name) ? null : name; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 6be840ce48..61975aad23 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -60,10 +60,20 @@ public abstract class IntegrationTest : IDisposable protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) { - return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false); + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler: null, functionTools).ConfigureAwait(false); } protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider, httpRequestHandler: null, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IHttpRequestHandler? httpRequestHandler, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, IHttpRequestHandler? httpRequestHandler, params IEnumerable functionTools) { AzureAgentProvider agentProvider = new(this.TestEndpoint, TestAzureCliCredentials.CreateAzureCliCredential()) @@ -82,7 +92,8 @@ public abstract class IntegrationTest : IDisposable { ConversationId = conversationId, LoggerFactory = this.Output, - McpToolHandler = mcpToolProvider + McpToolHandler = mcpToolProvider, + HttpRequestHandler = httpRequestHandler, }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index bd5b250eb8..ec09197376 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -45,6 +45,15 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati #endregion + #region InvokeHttpRequest Tests + + [RetryTheory(3, 5000)] + [InlineData("HttpRequest.yaml", "visibility: public")] + public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) => + this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains); + + #endregion + #region InvokeFunctionTool Test Helpers /// @@ -250,6 +259,40 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati #endregion + #region InvokeHttpRequest Test Helpers + + /// + /// Runs an HttpRequestAction workflow test with the specified configuration. + /// + private async Task RunHttpRequestTestAsync( + string workflowFileName, + string? expectedResultContains = null) + { + // Arrange + string workflowPath = GetWorkflowPath(workflowFileName); + await using DefaultHttpRequestHandler httpRequestHandler = new(); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( + externalConversation: false, + httpRequestHandler: httpRequestHandler); + + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + + // Act + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); + + // Assert - Verify executor and action events + AssertWorkflowEventsEmitted(workflowEvents); + + // Assert - Verify expected result if specified + if (expectedResultContains is not null) + { + AssertResultContains(workflowEvents, expectedResultContains); + } + } + + #endregion + #region Shared Helpers private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml new file mode 100644 index 0000000000..24ee0546e4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/HttpRequest.yaml @@ -0,0 +1,32 @@ +# +# This workflow tests invoking HttpRequestAction end-to-end. +# Uses the public GitHub API (unauthenticated) to fetch repo metadata. +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_http_request_test + actions: + + # Set the repo owner used to form the request URL. + - kind: SetVariable + id: set_repo_owner + variable: Local.RepoOwner + value: dotnet + + # Invoke the GitHub repo API. + - kind: HttpRequestAction + id: fetch_repo_info + conversationId: =System.ConversationId + method: GET + url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime") + headers: + Accept: application/vnd.github+json + User-Agent: agent-framework-integration-test + response: Local.RepoInfo + + # Surface the Repo visibility field from the parsed JSON response. + - kind: SendMessage + id: show_visibility + message: "visibility: {Local.RepoInfo.visibility}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 6c61d6cb7d..d3e828fef6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -181,6 +181,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("ResetVariable.yaml", 2, "clear_var")] [InlineData("MixedScopes.yaml", 2, "activity_input")] [InlineData("CaseInsensitive.yaml", 6, "end_when_match")] + [InlineData("HttpRequest.yaml", 1, "http_request")] public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId) { await this.RunWorkflowAsync(workflowFile); @@ -200,7 +201,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData(typeof(EmitEvent.Builder))] [InlineData(typeof(GetActivityMembers.Builder))] [InlineData(typeof(GetConversationMembers.Builder))] - [InlineData(typeof(HttpRequestAction.Builder))] [InlineData(typeof(InvokeAIBuilderModelAction.Builder))] [InlineData(typeof(InvokeConnectorAction.Builder))] [InlineData(typeof(InvokeCustomModelAction.Builder))] @@ -266,6 +266,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("SendActivity.yaml", "activity_input")] [InlineData("SetVariable.yaml", "set_var")] [InlineData("SetTextVariable.yaml", "set_text")] + [InlineData("HttpRequest.yaml", "http_request")] public async Task CancelRunAsync(string workflowPath, string expectedExecutedId) { // Arrange @@ -374,7 +375,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); - DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; + DeclarativeWorkflowOptions workflowContext = + new(mockAgentProvider.Object) + { + LoggerFactory = this.Output, + HttpRequestHandler = CreateMockHttpRequestHandler().Object, + }; return DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); } @@ -385,4 +391,18 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input))); return mockAgentProvider; } + + private static Mock CreateMockHttpRequestHandler() + { + Mock mockHandler = new(MockBehavior.Loose); + mockHandler + .Setup(handler => handler.SendAsync(It.IsAny(), It.IsAny())) + .Returns(() => Task.FromResult(new HttpRequestResult + { + StatusCode = 200, + IsSuccessStatusCode = true, + Body = "{\"ok\":true}", + })); + return mockHandler; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs new file mode 100644 index 0000000000..e679a89ca3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class DefaultHttpRequestHandlerTests +{ + private static readonly string[] s_setCookieValues = ["a=1", "b=2"]; + + private const string TestUrl = "https://api.example.test/resource"; + + #region Constructor Tests + + [Fact] + public async Task ConstructorWithNoParametersCreatesInstanceAsync() + { + // Act + await using DefaultHttpRequestHandler handler = new(); + + // Assert + handler.Should().NotBeNull(); + } + + [Fact] + public async Task ConstructorWithNullProviderCreatesInstanceAsync() + { + // Act + await using DefaultHttpRequestHandler handler = new(httpClientProvider: null); + + // Assert + handler.Should().NotBeNull(); + } + + [Fact] + public void ConstructorWithNullHttpClientThrows() + { + // Act + Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + })); + using HttpClient suppliedClient = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(suppliedClient); + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert - the supplied HttpClient's underlying handler saw the request + messageHandler.LastRequest.Should().NotBeNull(); + messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl); + result.Body.Should().Be("ok"); + } + + [Fact] + public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + using HttpClient suppliedClient = new(messageHandler); + + // Act + DefaultHttpRequestHandler handler = new(suppliedClient); + await handler.DisposeAsync(); + + // Assert - supplied client remains usable (not disposed) + Func act = async () => await suppliedClient.GetAsync(new Uri(TestUrl)); + await act.Should().NotThrowAsync(); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public async Task SendAsyncWithNullRequestThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + + // Act + Func act = async () => await handler.SendAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncWithEmptyUrlThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + HttpRequestInfo request = new() { Method = "GET", Url = "" }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncWithEmptyMethodThrowsAsync() + { + // Arrange + await using DefaultHttpRequestHandler handler = new(); + HttpRequestInfo request = new() { Method = "", Url = TestUrl }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion + + #region Send Behavior Tests + + [Fact] + public async Task SendAsyncUsesProvidedHttpClientAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("hello", Encoding.UTF8, "text/plain"), + })); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest.Should().NotBeNull(); + messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get); + messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl); + result.StatusCode.Should().Be(200); + result.IsSuccessStatusCode.Should().BeTrue(); + result.Body.Should().Be("hello"); + } + + [Fact] + public async Task SendAsyncMapsAllKnownMethodsAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" }) + { + HttpRequestInfo request = new() { Method = method, Url = TestUrl }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Method.Method.Should().Be(method); + } + } + + [Fact] + public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl }; + + // Act + await handler.SendAsync(request); + + // Assert - fallback path should apply the same Trim/ToUpperInvariant normalization. + messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM"); + } + + [Fact] + public async Task SendAsyncAppliesBodyAndContentTypeAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "{\"hello\":\"world\"}", + BodyContentType = "application/json", + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}"); + messageHandler.LastRequestContentType.Should().Be("application/json"); + } + + [Fact] + public async Task SendAsyncAppliesRequestHeadersAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Headers = new Dictionary + { + ["Authorization"] = "Bearer secret", + ["Accept"] = "application/json", + }, + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret"); + messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json"); + } + + [Fact] + public async Task SendAsyncRoutesContentHeadersToBodyAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "raw", + BodyContentType = "text/plain", + Headers = new Dictionary + { + ["Content-Language"] = "en-US", + }, + }; + + // Act + await handler.SendAsync(request); + + // Assert + messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US"); + } + + [Fact] + public async Task SendAsyncCapturesResponseHeadersAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + { +#pragma warning disable CA2025 + HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; + response.Headers.Add("X-Request-Id", "request-1"); + response.Headers.Add("Set-Cookie", s_setCookieValues); + return Task.FromResult(response); +#pragma warning restore CA2025 + }); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + result.Headers.Should().NotBeNull(); + result.Headers!.Should().ContainKey("X-Request-Id"); + result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues); + // Content headers also flattened in. + result.Headers!.Should().ContainKey("Content-Type"); + } + + [Fact] + public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad request", Encoding.UTF8, "text/plain"), + })); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() { Method = "GET", Url = TestUrl }; + + // Act + HttpRequestResult result = await handler.SendAsync(request); + + // Assert + result.IsSuccessStatusCode.Should().BeFalse(); + result.StatusCode.Should().Be(400); + result.Body.Should().Be("bad request"); + } + + [Fact] + public async Task SendAsyncTimeoutCancelsRequestAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new(async (req, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(50), + }; + + // Act + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() + { + // Arrange + int providerCallCount = 0; + await using DefaultHttpRequestHandler handler = new((_, _) => + { + providerCallCount++; + return Task.FromResult(null); + }); + + HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" }; + + // Act - owned client will attempt real network and fail, but provider path should have been consulted first. + Func act = async () => await handler.SendAsync(request); + + // Assert + await act.Should().ThrowAsync(); + providerCallCount.Should().Be(1); + } + + #endregion + + #region DisposeAsync + + [Fact] + public async Task DisposeAsyncCompletesAsync() + { + // Arrange + DefaultHttpRequestHandler handler = new(); + + // Act + Func act = async () => await handler.DisposeAsync(); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync() + { + // Arrange + DefaultHttpRequestHandler handler = new(); + + // Act + await handler.DisposeAsync(); + Func second = async () => await handler.DisposeAsync(); + + // Assert + await second.Should().NotThrowAsync(); + } + + #endregion + + #region Query Parameters and Connection Tests + + [Fact] + public async Task QueryParametersAreAppendedToUrlAsync() + { + // Arrange + TestHttpMessageHandler fake = new(static (req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) })); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake))); + + HttpRequestInfo info = new() + { + Method = "GET", + Url = TestUrl, + QueryParameters = new Dictionary + { + ["filter"] = "active items", + ["ids"] = "1,2,3", + }, + }; + + // Act + await handler.SendAsync(info); + + // Assert + fake.LastRequest.Should().NotBeNull(); + string? query = fake.LastRequest!.RequestUri!.Query; + query.Should().Contain("filter=active%20items"); + query.Should().Contain("ids=1%2C2%2C3"); + } + + [Fact] + public async Task QueryParametersPreserveExistingQueryStringAsync() + { + // Arrange + TestHttpMessageHandler fake = new(static (req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) })); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(fake))); + + HttpRequestInfo info = new() + { + Method = "GET", + Url = TestUrl + "?existing=yes", + QueryParameters = new Dictionary + { + ["added"] = "true", + }, + }; + + // Act + await handler.SendAsync(info); + + // Assert + fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true"); + } + + #endregion + + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _responseFactory; + + public TestHttpMessageHandler(Func> responseFactory) + { + this._responseFactory = responseFactory; + } + + public HttpRequestMessage? LastRequest { get; private set; } + + public string? LastRequestBody { get; private set; } + + public string? LastRequestContentType { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + if (request.Content is not null) + { +#if NET + this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType; + } + return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs new file mode 100644 index 0000000000..bba8b4d64d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/HttpRequestExecutorTest.cs @@ -0,0 +1,759 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Moq; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + private const string TestUrl = "https://api.example.com/data"; + + private readonly Mock _agentProvider = new(MockBehavior.Loose); + + [Fact] + public void InvalidModel() + { + // Arrange + Mock mockHandler = new(); + + // Act & Assert + Assert.Throws(() => new HttpRequestExecutor( + new HttpRequestAction(), + mockHandler.Object, + this._agentProvider.Object, + this.State)); + } + + [Fact] + public void HttpRequestIsDiscreteAction() + { + // Arrange + Mock mockHandler = new(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestIsDiscreteAction), + url: TestUrl, + method: HttpMethodType.Get); + HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State); + + // Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action). + VerifyIsDiscrete(action, isDiscrete: true); + } + + [Fact] + public async Task HttpGetReturnsJsonObjectAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ResponseVar = "Result"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetReturnsJsonObjectAsync), + url: TestUrl, + method: HttpMethodType.Get, + responseVariable: ResponseVar); + + MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + Assert.IsType(this.State.Get(ResponseVar), exactMatch: false); + handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl); + } + + [Fact] + public async Task HttpGetReturnsPlainStringAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ResponseVar = "Result"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetReturnsPlainStringAsync), + url: TestUrl, + method: HttpMethodType.Get, + responseVariable: ResponseVar); + + MockHttpRequestHandler handler = new(HttpRequestResult("not-json content")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this.VerifyState(ResponseVar, FormulaValue.New("not-json content")); + } + + [Fact] + public async Task HttpGetWithEmptyBodyYieldsBlankAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ResponseVar = "Result"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync), + url: TestUrl, + method: HttpMethodType.Get, + responseVariable: ResponseVar); + + MockHttpRequestHandler handler = new(HttpRequestResult(null)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this.VerifyUndefined(ResponseVar); + } + + [Fact] + public async Task HttpGetForwardsHeadersAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetForwardsHeadersAsync), + url: TestUrl, + method: HttpMethodType.Get, + headers: new Dictionary + { + ["Authorization"] = "Bearer token", + ["Accept"] = "application/json", + }); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.Headers?["Authorization"] == "Bearer token" && + info.Headers?["Accept"] == "application/json"); + } + + [Fact] + public async Task HttpPostWithJsonBodyAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpPostWithJsonBodyAsync), + url: TestUrl, + method: HttpMethodType.Post, + jsonBody: new StringDataValue("hello")); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.Method == "POST" && + info.BodyContentType == "application/json" && + info.Body == "\"hello\""); + } + + [Fact] + public async Task HttpPostWithRawBodyAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpPostWithRawBodyAsync), + url: TestUrl, + method: HttpMethodType.Post, + rawBody: "raw body content", + rawContentType: "text/plain"); + + MockHttpRequestHandler handler = new(HttpRequestResult("")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.BodyContentType == "text/plain" && + info.Body == "raw body content"); + } + + [Fact] + public async Task HttpRequestRaisesOnErrorByDefaultAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act & Assert + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + } + + [Fact] + public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync), + url: TestUrl, + method: HttpMethodType.Get); + + string longBody = new('x', 10_000); + MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + DeclarativeActionException exception = + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + + // Assert - message contains status and truncation marker, bounded in length, never the full body. + Assert.Contains("500", exception.Message); + Assert.Contains("[truncated]", exception.Message); + Assert.DoesNotContain(longBody, exception.Message); + Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars."); + } + + [Fact] + public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + DeclarativeActionException exception = + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + + // Assert - status present, no stray "Body: ''" noise. + Assert.Contains("404", exception.Message); + Assert.DoesNotContain("Body:", exception.Message); + } + + [Fact] + public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + DeclarativeActionException exception = + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + + // Assert - CR/LF/TAB collapsed to spaces so the message stays on one line. + Assert.DoesNotContain("\r", exception.Message); + Assert.DoesNotContain("\n", exception.Message); + Assert.DoesNotContain("\t", exception.Message); + Assert.Contains("line1", exception.Message); + Assert.Contains("line2", exception.Message); + } + + [Fact] + public async Task HttpRequestPassesTimeoutToHandlerAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync), + url: TestUrl, + method: HttpMethodType.Get, + timeoutMilliseconds: 1500); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.Timeout is not null && + info.Timeout.Value == TimeSpan.FromMilliseconds(1500)); + } + + [Fact] + public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new( + HttpRequestResult("{}"), + throwOnSend: new OperationCanceledException()); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act & Assert + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + } + + [Fact] + public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new( + HttpRequestResult("{}"), + throwOnSend: new InvalidOperationException("transport failure")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act & Assert + await Assert.ThrowsAsync(() => this.ExecuteAsync(action)); + } + + [Fact] + public async Task HttpRequestStoresResponseHeadersAsync() + { + // Arrange + this.State.InitializeSystem(); + const string HeaderVar = "Headers"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestStoresResponseHeadersAsync), + url: TestUrl, + method: HttpMethodType.Get, + responseHeadersVariable: HeaderVar); + + Dictionary> responseHeaders = new(StringComparer.OrdinalIgnoreCase) + { + ["X-Request-Id"] = ["abc-123"], + ["Set-Cookie"] = ["a=1", "b=2"], + }; + MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + FormulaValue storedHeaders = this.State.Get(HeaderVar); + Assert.IsType(storedHeaders, exactMatch: false); + } + + [Fact] + public async Task HttpRequestForwardsQueryParametersAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestForwardsQueryParametersAsync), + url: TestUrl, + method: HttpMethodType.Get, + queryParameters: new Dictionary + { + ["filter"] = StringDataValue.Create("active"), + ["limit"] = NumberDataValue.Create(10), + ["includeDeleted"] = BooleanDataValue.Create(false), + }); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.QueryParameters?.Count == 3 && + info.QueryParameters["filter"] == "active" && + info.QueryParameters["limit"] == "10" && + info.QueryParameters["includeDeleted"] == "false"); + } + + [Fact] + public async Task HttpRequestAddsResponseToConversationAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ConversationId = "conv-12345"; + const string ResponseBody = "response-text"; + + this._agentProvider + .Setup(p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, message, _) => Task.FromResult(message)); + + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestAddsResponseToConversationAsync), + url: TestUrl, + method: HttpMethodType.Get, + conversationId: ConversationId); + + MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody)); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this._agentProvider.Verify( + p => p.CreateMessageAsync( + ConversationId, + It.Is(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HttpRequestWithoutConversationIdSkipsConversationAsync() + { + // Arrange + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync), + url: TestUrl, + method: HttpMethodType.Get); + + MockHttpRequestHandler handler = new(HttpRequestResult("response")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this._agentProvider.Verify( + p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HttpRequestForwardsConnectionNameAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ConnectionName = "my-connection"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestForwardsConnectionNameAsync), + url: TestUrl, + method: HttpMethodType.Get, + connectionName: ConnectionName); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => info.ConnectionName == ConnectionName); + } + + [Fact] + public async Task HttpRequestEmptyConversationIdSkipsConversationAsync() + { + // Arrange - empty-string conversationId should be treated as unset. + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync), + url: TestUrl, + method: HttpMethodType.Get, + conversationId: ""); + + MockHttpRequestHandler handler = new(HttpRequestResult("response")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this._agentProvider.Verify( + p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HttpRequestEmptyResponseBodySkipsConversationAsync() + { + // Arrange - conversationId set, but empty body should not produce a conversation message. + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync), + url: TestUrl, + method: HttpMethodType.Get, + conversationId: "conv-1"); + + MockHttpRequestHandler handler = new(HttpRequestResult("")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + this._agentProvider.Verify( + p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HttpGetReturnsJsonArrayAsync() + { + // Arrange - exercises JsonValueKind.Array branch of ParseResponseBody. + this.State.InitializeSystem(); + const string ResponseVar = "Result"; + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetReturnsJsonArrayAsync), + url: TestUrl, + method: HttpMethodType.Get, + responseVariable: ResponseVar); + + MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + FormulaValue stored = this.State.Get(ResponseVar); + Assert.IsType(stored, exactMatch: false); + } + + [Fact] + public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync() + { + // Arrange - empty header values should be filtered out (matches GetHeaders guard). + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync), + url: TestUrl, + method: HttpMethodType.Get, + headers: new Dictionary + { + ["X-Trace"] = "trace-1", + ["X-Empty"] = "", + }); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => + info.Headers?.ContainsKey("X-Trace") == true && + info.Headers?.ContainsKey("X-Empty") == false); + } + + [Fact] + public async Task HttpRequestZeroTimeoutNotForwardedAsync() + { + // Arrange - non-positive timeouts should not be forwarded (handler default applies). + this.State.InitializeSystem(); + HttpRequestAction model = this.CreateModel( + displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync), + url: TestUrl, + method: HttpMethodType.Get, + timeoutMilliseconds: 0); + + MockHttpRequestHandler handler = new(HttpRequestResult("{}")); + HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State); + + // Act + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + handler.VerifySent(info => info.Timeout is null); + } + + private static HttpRequestResult HttpRequestResult( + string? body, + int statusCode = 200, + bool isSuccess = true, + IReadOnlyDictionary>? headers = null) => + new() + { + StatusCode = statusCode, + IsSuccessStatusCode = isSuccess, + Body = body, + Headers = headers, + }; + + private HttpRequestAction CreateModel( + string displayName, + string url, + HttpMethodType method, + string? responseVariable = null, + string? responseHeadersVariable = null, + IReadOnlyDictionary? headers = null, + IReadOnlyDictionary? queryParameters = null, + string? conversationId = null, + string? connectionName = null, + DataValue? jsonBody = null, + string? rawBody = null, + string? rawContentType = null, + long? timeoutMilliseconds = null, + string? continueOnErrorStatusVariable = null, + string? continueOnErrorBodyVariable = null) + { + HttpRequestAction.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Url = new StringExpression.Builder(StringExpression.Literal(url)), + Method = new EnumExpression.Builder( + EnumExpression.Literal(HttpMethodTypeWrapper.Get(method))), + }; + + if (responseVariable is not null) + { + builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable)); + } + + if (responseHeadersVariable is not null) + { + builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable)); + } + + if (headers is not null) + { + foreach (KeyValuePair header in headers) + { + builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value))); + } + } + + if (queryParameters is not null) + { + foreach (KeyValuePair parameter in queryParameters) + { + builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value))); + } + } + + if (conversationId is not null) + { + builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId)); + } + + if (connectionName is not null) + { + builder.Connection = new RemoteConnection.Builder + { + Name = new StringExpression.Builder(StringExpression.Literal(connectionName)), + }; + } + + if (jsonBody is not null) + { + builder.Body = new JsonRequestContent.Builder() + { + Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)), + }; + } + else if (rawBody is not null) + { + RawRequestContent.Builder rawBuilder = new() + { + Content = new StringExpression.Builder(StringExpression.Literal(rawBody)), + }; + if (rawContentType is not null) + { + rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType)); + } + builder.Body = rawBuilder; + } + + if (timeoutMilliseconds is not null) + { + builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value)); + } + + if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null) + { + ContinueOnErrorBehavior.Builder continueBuilder = new(); + if (continueOnErrorStatusVariable is not null) + { + continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable)); + } + if (continueOnErrorBodyVariable is not null) + { + continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable)); + } + builder.ErrorHandling = continueBuilder; + } + + return AssignParent(builder); + } + + private sealed class MockHttpRequestHandler : Mock + { + private HttpRequestInfo? _lastRequest; + + public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null) + { + this.Setup(handler => handler.SendAsync(It.IsAny(), It.IsAny())) + .Returns((info, _) => + { + this._lastRequest = info; + if (throwOnSend is not null) + { + throw throwOnSend; + } + return Task.FromResult(result); + }); + } + + public void VerifySent(Func predicate) + { + Assert.NotNull(this._lastRequest); + Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate."); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml new file mode 100644 index 0000000000..0eb89fe8d8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/HttpRequest.yaml @@ -0,0 +1,15 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: my_workflow + actions: + + - kind: HttpRequestAction + id: http_request + method: GET + url: =Concatenate("https://api.example.test/items/", System.LastMessageText) + headers: + Accept: application/json + response: Local.HttpResult + responseHeaders: Local.HttpHeaders From 866a325b481b3363d60db14f9889f9c80f0dbc3b Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:35:36 +0900 Subject: [PATCH 3/3] Python: [BREAKING] Standardize orchestration terminal outputs as AgentResponse (#5301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix orchestration outputs so as_agent() returns the final answer only. Align other orchestration outputs * Fix orchestration output issues from review comments 1. Sample cleanup: Remove commented-out FoundryChatClient block and update prerequisites to reference OPENAI_CHAT_MODEL_ID instead of FOUNDRY_* vars. 2. Sequential approval output: Change _EndWithConversation.end_with_agent_executor_response from a no-op sink to yield response.agent_response. When the last participant is AgentApprovalExecutor (via with_request_info), _EndWithConversation is the output executor so the yield produces the terminal answer. When the last participant is a regular AgentExecutor, _EndWithConversation is not in output_executors so the yield is silently filtered out. 3. Forward data events through WorkflowExecutor: _process_workflow_result now also forwards 'data' events from sub-workflows so that emit_intermediate_data=True on AgentExecutor works correctly when wrapped in AgentApprovalExecutor. 4. Concurrent docstring: Update _AggregateAgentConversations docstring to say 'deterministic participant order' instead of 'completion order'. 5. Add test_concurrent_intermediate_outputs_emits_data_events verifying that ConcurrentBuilder(intermediate_outputs=True) emits per-participant data events alongside the single aggregated output event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add tests for sequential workflow with_request_info and intermediate_outputs (#5301) Address PR review comments 2, 3, and 5: - Add test_sequential_request_info_last_participant_emits_output: Verifies that when the last participant is wrapped via with_request_info() (AgentApprovalExecutor), the workflow still emits a terminal output after approval, exercising the _EndWithConversation.end_with_agent_executor_response fallback path. - Add test_sequential_request_info_with_intermediate_outputs_emits_data_events: Verifies that emit_intermediate_data=True works correctly through AgentApprovalExecutor wrapping—WorkflowExecutor._process_result already forwards data events from sub-workflows, so intermediate agent responses surface as data events in the parent workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright type errors from AgentResponse output refactor (#5301) Update cast() calls in _group_chat.py and _magentic.py to use WorkflowContext[Never, AgentResponse] instead of the old WorkflowContext[Never, list[Message]], matching the updated method signatures in _base_group_chat_orchestrator.py. Fix _sequential.py _EndWithConversation.end_with_agent_executor_response to declare WorkflowContext[Any, AgentResponse] so yield_output accepts AgentResponse[None]. Fix _workflow_executor.py data event forwarding to handle nullable executor_id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright reportUnknownVariableType in _agent.py (#5301) Extract event.data into a typed local variable before the isinstance check to avoid pyright narrowing it to AgentResponse[Unknown]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright reportMissingImports for orjson in file history samples (#5301) Add pyright: ignore[reportMissingImports] to orjson imports that are already guarded by try/except ImportError, matching the existing pattern used elsewhere in the samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5301: review comment fixes * Address review feedback for #5301: review comment fixes * Revert sequential_workflow_as_agent sample to FoundryChatClient Reverts the mistaken switch from FoundryChatClient to OpenAIChatClient in the sequential workflow as agent sample. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address ultrareview feedback: emit_data_events rename + WorkflowAgent reasoning conversion Layered on top of the prior review-feedback work in this branch. Renames: - AgentExecutor.emit_intermediate_data -> emit_data_events (mechanical rename; orchestration semantics live at the orchestration layer, not the general-purpose executor). Forwarded through MagenticAgentExecutor, AgentApprovalExecutor, and all orchestration call sites. - HandoffAgentExecutor._check_terminate_and_yield -> _should_terminate (pure predicate; no longer yields anything). HandoffBuilder docstring rewritten to describe the new per-agent AgentResponse output contract. WorkflowAgent reasoning-content conversion: - Add _rewrite_text_to_reasoning(contents) and _msg_as_reasoning(msg) helpers; the as_agent() path now reframes text content from data events as text_reasoning Content blocks before merging into the AgentResponse. - Consumers iterate msg.contents and branch on content.type — same path they already use for Claude thinking and OpenAI reasoning. No new field on Message/AgentResponse/WorkflowEvent. - Streaming branch constructs fresh AgentResponseUpdate instances instead of mutating shared payloads (regression test added). - Helper _msg_maybe_reasoning consolidates the conditional rewrite at three call sites in the non-streaming conversion. Tests: - TestWorkflowAgentReasoningHelpers + TestWorkflowAgentDataEventReasoningConversion add 9 new tests covering helpers, non-streaming, streaming, mixed content, already-reasoning passthrough, and mutation-safety regression. - Updated test_sequential_as_agent_with_intermediate_outputs_includes_chain to assert text_reasoning content for intermediate agents. * Fix pyright: widen event.data to Any to avoid partial-unknown narrowing The streaming conversion path narrowed event.data via isinstance against generic AgentResponse, producing AgentResponse[Unknown] and tripping reportUnknownVariableType/reportUnknownMemberType. Binding data: Any before the check keeps runtime behavior identical while restoring a fully known type for downstream access. * Clean up design * Scope to agent output semantics only * yield AgentResponseUpdate streaming, AgentResponse non-streaming * Fix mypy/pyright: widen cast types at GroupChat callsites Eight callsites in _group_chat.py still cast to WorkflowContext[Never, AgentResponse] but the base orchestrator methods now accept the wider WorkflowContext[Never, AgentResponse | AgentResponseUpdate] (mode-aware yields). W_OutT is invariant, so the narrower cast is not assignable. Magentic was widened in the same commit; this catches the GroupChat callsites that were missed. * Python: skip flaky Foundry / Foundry Hosting integration tests (#5553) These two integration tests have been failing in the merge queue across multiple unrelated PRs (5301, 5531). Both are marked `@pytest.mark.flaky` with 3 retries, but all attempts fail back-to-back. Skipping both with a reason pointing to #5553 so they can be fixed properly without continuing to block unrelated merges. - packages/foundry_hosting/tests/test_responses_int.py::TestOptions::test_temperature_and_max_tokens - packages/foundry/tests/foundry/test_foundry_embedding_client.py::TestFoundryEmbeddingIntegration::test_text_embedding_live Also includes a one-line uv.lock specifier-ordering normalization auto-applied by the poe-check pre-commit hook. --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 + .../core/agent_framework/_workflows/_agent.py | 20 +- .../_workflows/_agent_executor.py | 5 +- .../_workflows/_workflow_executor.py | 4 +- .../tests/workflow/test_workflow_kwargs.py | 24 +- .../foundry/test_foundry_embedding_client.py | 1 + .../tests/test_responses_int.py | 1 + .../_base_group_chat_orchestrator.py | 48 +++- .../_concurrent.py | 43 ++- .../_group_chat.py | 46 +-- .../_handoff.py | 37 ++- .../_magentic.py | 44 +-- .../_orchestration_request_info.py | 95 ++++++- .../_sequential.py | 122 +++----- .../orchestrations/tests/test_concurrent.py | 102 +++++-- .../orchestrations/tests/test_group_chat.py | 266 ++++++++++-------- .../orchestrations/tests/test_handoff.py | 32 ++- .../orchestrations/tests/test_magentic.py | 98 +++++-- .../orchestrations/tests/test_sequential.py | 188 +++++++++---- .../agents/sequential_workflow_as_agent.py | 30 +- .../sequential_custom_executors.py | 63 ++--- python/uv.lock | 2 +- 22 files changed, 785 insertions(+), 490 deletions(-) diff --git a/.gitignore b/.gitignore index da436cd090..283f626207 100644 --- a/.gitignore +++ b/.gitignore @@ -242,3 +242,7 @@ python/dotnet-ref # Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) dotnet/filtered-*.slnx **/*.lscache + +# Local tool state +.omc/ +.omx/ diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 2fd3f35213..111ef61e3b 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -528,6 +528,7 @@ class WorkflowAgent(BaseAgent): raw_representations.append(output_event) else: data = output_event.data + if isinstance(data, AgentResponseUpdate): # We cannot support AgentResponseUpdate in non-streaming mode. This is because the message # sequence cannot be guaranteed when there are streaming updates in between non-streaming @@ -628,16 +629,23 @@ class WorkflowAgent(BaseAgent): A list of AgentResponseUpdate objects. Empty list if the event is not relevant. """ if event.type == "output": - # Convert workflow output to agent response updates. - # Handle different data types appropriately. data = event.data executor_id = event.executor_id if isinstance(data, AgentResponseUpdate): - # Pass through AgentResponseUpdate directly (streaming from AgentExecutor) - if not data.author_name: - data.author_name = executor_id - return [data] + # Construct a fresh AgentResponseUpdate so we don't mutate a payload + # that AgentExecutor still holds a reference to in its `updates` list. + return [ + AgentResponseUpdate( + contents=list(data.contents), + role=data.role, + author_name=data.author_name or executor_id, + response_id=data.response_id, + message_id=data.message_id, + created_at=data.created_at, + raw_representation=data.raw_representation, + ) + ] if isinstance(data, AgentResponse): # Convert each message in AgentResponse to an AgentResponseUpdate updates: list[AgentResponseUpdate] = [] diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 626a02199b..9b16b1f291 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -156,8 +156,9 @@ class AgentExecutor(Executor): the agent run. - "custom": use the provided context_filter function to determine which messages to include as context for the agent run. - context_filter: An optional function for filtering conversation context when context_mode is set - to "custom". + context_filter: A function that takes the full conversation (list of Messages) as input and returns + a filtered list of Messages to be used as context for the agent run. This is required + if context_mode is set to "custom". """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or resolve_agent_id(agent) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index afb6145251..847b44863c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -361,7 +361,7 @@ class WorkflowExecutor(Executor): return any(is_instance_of(message.data, input_type) for input_type in self.workflow.input_types) @handler - async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None: + async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, Any]) -> None: """Execute the sub-workflow with raw input data. This handler starts a new sub-workflow execution. When the sub-workflow @@ -428,7 +428,7 @@ class WorkflowExecutor(Executor): async def handle_message_wrapped_request_response( self, response: SubWorkflowResponseMessage, - ctx: WorkflowContext[Any], + ctx: WorkflowContext[Any, Any], ) -> None: """Handle response from parent for a forwarded request. diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index bba808f87a..9c664c6ac2 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -232,16 +232,18 @@ async def test_groupchat_kwargs_flow_to_agents() -> None: async def test_kwargs_stored_in_state() -> None: """Test that function_invocation_kwargs are stored in State with the correct key.""" - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler stored_kwargs: dict[str, Any] | None = None class _StateInspector(Executor): @handler - async def inspect(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def inspect(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) inspector = _StateInspector(id="inspector") workflow = SequentialBuilder(participants=[inspector]).build() @@ -256,16 +258,18 @@ async def test_kwargs_stored_in_state() -> None: async def test_empty_kwargs_stored_as_empty_dict() -> None: """Test that empty kwargs are stored as empty dict in State.""" - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler stored_kwargs: Any = "NOT_CHECKED" class _StateChecker(Executor): @handler - async def check(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def check(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: nonlocal stored_kwargs stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) checker = _StateChecker(id="checker") workflow = SequentialBuilder(participants=[checker]).build() @@ -695,7 +699,9 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: Verifies that WORKFLOW_RUN_KWARGS_KEY is populated in the subworkflow's State with kwargs from the parent workflow. """ - from agent_framework import Executor, WorkflowContext, handler + from typing_extensions import Never + + from agent_framework import AgentResponse, Executor, WorkflowContext, handler from agent_framework._workflows._workflow_executor import WorkflowExecutor captured_kwargs_from_state: list[dict[str, Any]] = [] @@ -704,10 +710,10 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None: """Executor that reads kwargs from State for verification.""" @handler - async def read_kwargs(self, msgs: list[Message], ctx: WorkflowContext[list[Message]]) -> None: + async def read_kwargs(self, msgs: list[Message], ctx: WorkflowContext[Never, AgentResponse]) -> None: kwargs_from_state = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) captured_kwargs_from_state.append(kwargs_from_state or {}) - await ctx.send_message(msgs) + await ctx.yield_output(AgentResponse(messages=msgs)) # Build inner workflow with State reader state_reader = _StateReader(id="state_reader") diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index 664123637d..f005a737dc 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -303,6 +303,7 @@ skip_if_foundry_inference_integration_tests_disabled = pytest.mark.skipif( class TestFoundryEmbeddingIntegration: """Integration tests requiring a live Foundry inference endpoint.""" + @pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.") @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_inference_integration_tests_disabled diff --git a/python/packages/foundry_hosting/tests/test_responses_int.py b/python/packages/foundry_hosting/tests/test_responses_int.py index e64976989b..24c590f25c 100644 --- a/python/packages/foundry_hosting/tests/test_responses_int.py +++ b/python/packages/foundry_hosting/tests/test_responses_int.py @@ -559,6 +559,7 @@ class TestToolCalling: class TestOptions: """Verify chat options are passed through to the model.""" + @pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.") @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_hosting_integration_tests_disabled diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py index 86c85cc079..a4108b23f0 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_base_group_chat_orchestrator.py @@ -12,7 +12,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, ClassVar, TypeAlias -from agent_framework._types import Message +from agent_framework._types import AgentResponse, AgentResponseUpdate, Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._events import WorkflowEvent from agent_framework._workflows._executor import Executor, handler @@ -351,8 +351,10 @@ class BaseGroupChatOrchestrator(Executor, ABC): result = await result return result - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: - """Check termination conditions and yield completion if met. + async def _check_terminate_and_yield( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: + """Check termination conditions and yield the completion message if met. Args: ctx: Workflow context for yielding output @@ -362,12 +364,37 @@ class BaseGroupChatOrchestrator(Executor, ABC): """ terminate = await self._check_termination() if terminate: - self._append_messages([self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE) + self._append_messages([completion_message]) + await self._yield_completion(ctx, completion_message) return True return False + async def _yield_completion( + self, + ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate], + completion_message: Message, + ) -> None: + """Yield a synthesized terminal completion message in the right shape for the run mode. + + Mode-aware to mirror ``AgentExecutor`` semantics: + - Streaming (``ctx.is_streaming()``): yield a single ``AgentResponseUpdate`` so the + ``output`` event stream stays uniformly per-chunk. + - Non-streaming: yield the full ``AgentResponse``. + """ + if ctx.is_streaming(): + await ctx.yield_output( + AgentResponseUpdate( + contents=list(completion_message.contents), + role=completion_message.role, + author_name=completion_message.author_name, + message_id=completion_message.message_id, + ) + ) + else: + await ctx.yield_output(AgentResponse(messages=[completion_message])) + def _create_completion_message(self, message: str) -> Message: """Create a standardized completion message. @@ -490,8 +517,10 @@ class BaseGroupChatOrchestrator(Executor, ABC): return False - async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: - """Check round limit and yield completion if reached. + async def _check_round_limit_and_yield( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: + """Check round limit and yield the max-rounds completion message if reached. Args: ctx: Workflow context for yielding output @@ -501,8 +530,9 @@ class BaseGroupChatOrchestrator(Executor, ABC): """ reach_max_rounds = self._check_round_limit() if reach_max_rounds: - self._append_messages([self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE) + self._append_messages([completion_message]) + await self._yield_completion(ctx, completion_message) return True return False diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index d73b7e322b..e1a931019a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -6,7 +6,7 @@ import logging from collections.abc import Callable, Sequence from typing import Any -from agent_framework import Message, SupportsAgentRun +from agent_framework import AgentResponse, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage @@ -71,18 +71,20 @@ class _DispatchToAllParticipants(Executor): class _AggregateAgentConversations(Executor): - """Aggregates agent responses and completes with combined ChatMessages. + """Aggregates agent responses and completes with a single AgentResponse. - Emits a list[Message] shaped as: - [ single_user_prompt?, agent1_final_assistant, agent2_final_assistant, ... ] + Emits an `AgentResponse` whose `messages` are the final assistant message from each + participant (one message per agent), in deterministic participant order matching + the fan-in `sources` configuration. The user prompt is intentionally not included — + that is part of the input, not the answer. - - Extracts a single user prompt (first user message seen across results). - - For each result, selects the final assistant message (prefers agent_response.messages). - - Avoids duplicating the same user message per agent. + For each participant the final assistant message is sourced from + `r.agent_response.messages`, falling back to scanning `r.full_conversation` for + pathological executors that did not populate the response. """ @handler - async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[Message]]) -> None: + async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, AgentResponse]) -> None: if not results: logger.error("Concurrent aggregator received empty results list") raise ValueError("Aggregation failed: no results provided") @@ -91,12 +93,10 @@ class _AggregateAgentConversations(Executor): r = getattr(msg, "role", None) if r is None: return False - # Normalize both r and role to lowercase strings for comparison r_str = str(r).lower() if isinstance(r, str) or hasattr(r, "__str__") else r role_str = str(role).lower() return r_str == role_str - prompt_message: Message | None = None assistant_replies: list[Message] = [] for r in results: @@ -107,10 +107,6 @@ class _AggregateAgentConversations(Executor): f"{len(resp_messages)} response msgs, {len(r.full_conversation)} conversation msgs" ) - # Capture a single user prompt (first encountered across any conversation) - if prompt_message is None: - prompt_message = next((m for m in r.full_conversation if _is_role(m, "user")), None) - # Pick the final assistant message from the response; fallback to conversation search final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, "assistant")), None) if final_assistant is None: @@ -127,14 +123,7 @@ class _AggregateAgentConversations(Executor): logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results") raise RuntimeError("Aggregation failed: no assistant replies found") - output: list[Message] = [] - if prompt_message is not None: - output.append(prompt_message) - else: - logger.warning("No user prompt found in any conversation; emitting assistants only") - output.extend(assistant_replies) - - await ctx.yield_output(output) + await ctx.yield_output(AgentResponse(messages=assistant_replies)) class _CallbackAggregator(Executor): @@ -190,7 +179,8 @@ class ConcurrentBuilder: from agent_framework_orchestrations import ConcurrentBuilder - # Minimal: use default aggregator (returns list[Message]) + # Minimal: use default aggregator (yields one AgentResponse with one assistant + # message per participant) workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build() @@ -222,8 +212,9 @@ class ConcurrentBuilder: Args: participants: Sequence of agent or executor instances to run in parallel. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants - before aggregation. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the aggregator's. By default + (False) only the aggregator's output surfaces. """ self._participants: list[SupportsAgentRun | Executor] = [] self._aggregator: Executor | None = None @@ -383,7 +374,7 @@ class ConcurrentBuilder: - If request info is enabled, the orchestration emits a request info event with outputs from all participants before sending the outputs to the aggregator - Aggregator yields output and the workflow becomes idle. The output is either: - - list[Message] (default aggregator: one user + one assistant per agent) + - AgentResponse (default aggregator: one assistant message per participant) - custom payload from the provided aggregator Returns: diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 4f1c2f832a..9f7e011252 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -29,7 +29,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from typing import Any, ClassVar, cast -from agent_framework import Agent, AgentSession, Message, SupportsAgentRun +from agent_framework import Agent, AgentResponse, AgentResponseUpdate, AgentSession, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage @@ -169,7 +169,9 @@ class GroupChatOrchestrator(BaseGroupChatOrchestrator): """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return next_speaker = await self._get_next_speaker() @@ -198,9 +200,13 @@ class GroupChatOrchestrator(BaseGroupChatOrchestrator): messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_round_limit_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return next_speaker = await self._get_next_speaker() @@ -332,13 +338,15 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): """Initialize orchestrator state and start the conversation loop.""" self._append_messages(messages) # Termination condition will also be applied to the input messages - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[Message]], ctx), + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), ): return @@ -366,15 +374,19 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): # Remove tool-related content to prevent API errors from empty messages messages = clean_conversation_for_handoff(messages) self._append_messages(messages) - if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_terminate_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return - if await self._check_round_limit_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)): + if await self._check_round_limit_and_yield( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ): return agent_orchestration_output = await self._invoke_agent() if await self._check_agent_terminate_and_yield( agent_orchestration_output, - cast(WorkflowContext[Never, list[Message]], ctx), + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), ): return @@ -522,9 +534,9 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): async def _check_agent_terminate_and_yield( self, agent_orchestration_output: AgentOrchestrationOutput, - ctx: WorkflowContext[Never, list[Message]], + ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ) -> bool: - """Check if the agent requested termination and yield completion if so. + """Yield the orchestrator's completion if termination was requested. Args: agent_orchestration_output: Output from the orchestrator agent @@ -536,8 +548,9 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): final_message = ( agent_orchestration_output.final_message or "The conversation has been terminated by the agent." ) - self._append_messages([self._create_completion_message(final_message)]) - await ctx.yield_output(self._full_conversation) + completion_message = self._create_completion_message(final_message) + self._append_messages([completion_message]) + await self._yield_completion(ctx, completion_message) return True return False @@ -622,7 +635,9 @@ class GroupChatBuilder: True to terminate the conversation, False to continue. max_rounds: Optional maximum number of orchestrator rounds to prevent infinite conversations. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the orchestrator's. By default (False) + only the orchestrator's output surfaces. """ self._participants: dict[str, SupportsAgentRun | Executor] = {} self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] @@ -643,8 +658,7 @@ class GroupChatBuilder: self._request_info_enabled: bool = False self._request_info_filter: set[str] = set() - # Intermediate outputs - self._intermediate_outputs = intermediate_outputs + self._intermediate_outputs: bool = intermediate_outputs if participants is None and participant_factories is None: raise ValueError("Either participants or participant_factories must be provided.") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index c3e156096c..f555ab89b0 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -352,7 +352,7 @@ class HandoffAgentExecutor(AgentExecutor): self._full_conversation.extend(self._cache.copy()) # Check termination condition before running the agent - if await self._check_terminate_and_yield(ctx): + if await self._should_terminate(): return # Run the agent @@ -410,7 +410,7 @@ class HandoffAgentExecutor(AgentExecutor): # Re-evaluate termination after appending and broadcasting this response. # Without this check, workflows that become terminal due to the latest assistant # message would still emit request_info and require an unnecessary extra resume. - if await self._check_terminate_and_yield(ctx): + if await self._should_terminate(): return # Handle case where no handoff was requested @@ -447,10 +447,10 @@ class HandoffAgentExecutor(AgentExecutor): response: The user's response messages ctx: The workflow context - If the response is empty, it indicates termination of the handoff workflow. + If the response is empty, the handoff workflow terminates. Per-agent responses + already surfaced as `output` events; no terminal yield is needed. """ if not response: - await ctx.yield_output(self._full_conversation) return # Broadcast the user response to all other agents @@ -520,14 +520,12 @@ class HandoffAgentExecutor(AgentExecutor): return None - async def _check_terminate_and_yield(self, ctx: WorkflowContext[Any, Any]) -> bool: - """Check termination conditions and yield completion if met. + async def _should_terminate(self) -> bool: + """Pure predicate: return True iff the configured termination condition is satisfied. - Args: - ctx: Workflow context for yielding output - - Returns: - True if termination condition met and output yielded, False otherwise + Per-agent responses already surface as `output` events as agents speak, so the + handoff workflow has no terminal yield to make — this method only decides whether + the workflow should stop iterating. """ if self._termination_condition is None: return False @@ -535,12 +533,7 @@ class HandoffAgentExecutor(AgentExecutor): terminated = self._termination_condition(self._full_conversation) if inspect.isawaitable(terminated): terminated = await terminated - - if terminated: - await ctx.yield_output(self._full_conversation) - return True - - return False + return bool(terminated) @override async def on_checkpoint_save(self) -> dict[str, Any]: @@ -577,13 +570,15 @@ class HandoffBuilder: tool injection, and middleware — capabilities only available on ``Agent``. Outputs: - The final conversation history as a list of Message once the group chat completes. + Each agent's response surfaces as a workflow `output` event as it speaks; there is no + synthetic terminal event. Consumers iterating events see per-agent ``AgentResponse`` (or + ``AgentResponseUpdate`` while streaming) in conversation order. The workflow returns to + idle once the termination condition is met (or the user terminates an interactive run). Note: 1. Agents in handoff workflows must be ``Agent`` instances and support local tool calls. - 2. Handoff doesn't support intermediate outputs from agents. All outputs are returned as - they become available. This is because agents in handoff workflows are not considered - sub-agents of a central orchestrator, thus all outputs are directly emitted. + 2. Because each agent's response is itself a workflow output, handoff has no separate + "intermediate outputs" channel — every per-agent response is the primary output. """ def __init__( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 80031cd726..7f1854f914 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -14,6 +14,7 @@ from typing import Any, ClassVar, TypeVar, cast from agent_framework import ( AgentResponse, + AgentResponseUpdate, AgentSession, Message, SupportsAgentRun, @@ -1057,7 +1058,9 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): if self._magentic_context is None: raise RuntimeError("Context not initialized") # Check limits first - within_limits = await self._check_within_limits_or_complete(cast(WorkflowContext[Never, list[Message]], ctx)) + within_limits = await self._check_within_limits_or_complete( + cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx) + ) if not within_limits: return @@ -1092,7 +1095,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Check for task completion if self._progress_ledger.is_request_satisfied.answer: logger.info("Magentic Orchestrator: Task completed") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx)) return # Check for stalling or looping @@ -1116,7 +1119,7 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): if next_speaker not in self._participant_registry.participants: logger.warning(f"Invalid next speaker: {next_speaker}") - await self._prepare_final_answer(cast(WorkflowContext[Never, list[Message]], ctx)) + await self._prepare_final_answer(cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx)) return # Add instruction to conversation (assistant guidance) @@ -1192,20 +1195,25 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): # Start inner loop await self._run_inner_loop(ctx) - async def _prepare_final_answer(self, ctx: WorkflowContext[Never, list[Message]]) -> None: - """Prepare the final answer using the manager.""" + async def _prepare_final_answer(self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate]) -> None: + """Yield the manager's synthesized final answer. + + Mode-aware: streaming -> ``AgentResponseUpdate``, non-streaming → ``AgentResponse``. + See ``BaseGroupChatOrchestrator._yield_completion``. + """ if self._magentic_context is None: raise RuntimeError("Context not initialized") logger.info("Magentic Orchestrator: Preparing final answer") final_answer = await self._manager.prepare_final_answer(self._magentic_context.clone(deep=True)) - # Emit a completed event for the workflow - await ctx.yield_output([final_answer]) + await self._yield_completion(ctx, final_answer) self._terminated = True - async def _check_within_limits_or_complete(self, ctx: WorkflowContext[Never, list[Message]]) -> bool: + async def _check_within_limits_or_complete( + self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate] + ) -> bool: """Check if orchestrator is within operational limits. If limits are exceeded, yield a termination message and mark the workflow as terminated. @@ -1229,15 +1237,12 @@ class MagenticOrchestrator(BaseGroupChatOrchestrator): limit_type = "round" if hit_round_limit else "reset" logger.error(f"Magentic Orchestrator: Max {limit_type} count reached") - # Yield the full conversation with an indication of termination due to limits - await ctx.yield_output([ - *self._magentic_context.chat_history, - Message( - role="assistant", - contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], - author_name=MAGENTIC_MANAGER_NAME, - ), - ]) + termination_message = Message( + role="assistant", + contents=[f"Workflow terminated due to reaching maximum {limit_type} count."], + author_name=MAGENTIC_MANAGER_NAME, + ) + await self._yield_completion(ctx, termination_message) self._terminated = True return False @@ -1427,7 +1432,9 @@ class MagenticBuilder: max_round_count: Max total coordination rounds. None means unlimited. enable_plan_review: If True, requires human approval of the initial plan before proceeding. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the orchestrator's. By default (False) + only the orchestrator's output surfaces. """ self._participants: dict[str, SupportsAgentRun | Executor] = {} @@ -1440,7 +1447,6 @@ class MagenticBuilder: self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage - # Intermediate outputs self._intermediate_outputs = intermediate_outputs self._set_participants(participants) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index e78d1bef14..23e382aa13 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Literal from agent_framework._agents import SupportsAgentRun -from agent_framework._types import Message +from agent_framework._types import AgentResponse, Message from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._executor import Executor, handler @@ -86,7 +86,13 @@ class AgentRequestInfoResponse: class AgentRequestInfoExecutor(Executor): - """Executor for gathering request info from users to assist agents.""" + """Executor for gathering request info from users to assist agents. + + On approval (caller returned no follow-up messages), yields the original + ``AgentExecutorResponse`` so downstream ``AgentExecutor`` participants can consume it + via their ``from_response`` handler — i.e., the inner workflow's output type matches the + chain currency used between Sequential participants. + """ @handler async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None: @@ -109,6 +115,56 @@ class AgentRequestInfoExecutor(Executor): await ctx.yield_output(original_request) +class _TerminalAgentRequestInfoExecutor(Executor): + """Sibling of ``AgentRequestInfoExecutor`` used when ``AgentApprovalExecutor`` is the workflow's terminator. + + This exists because: + - The orchestration contract established is that every orchestration's terminal + ``output`` event carries an ``AgentResponse``. That is the user-facing promise — e.g., + ``workflow.as_agent().run(prompt)`` returns an ``AgentResponse``. + - ``AgentRequestInfoExecutor`` yields ``AgentExecutorResponse`` because that is the chain + currency between Sequential participants: the next ``AgentExecutor`` consumes + ``AgentExecutorResponse`` via its ``from_response`` handler. That is correct when + ``AgentApprovalExecutor`` is *intermediate*. + - When ``AgentApprovalExecutor`` is the *terminator* (``allow_direct_output=True``), the + inner yield flows straight through ``WorkflowExecutor`` to the outer workflow's terminal + output. Yielding ``AgentExecutorResponse`` there would surface ``AgentExecutorResponse`` + as the workflow's terminal output — violating the orchestration contract. + + Used in place of ``AgentRequestInfoExecutor`` inside the terminator-mode inner workflow + built by ``AgentApprovalExecutor._build_workflow`` when ``allow_direct_output=True``. + + Translation belongs here — at the source of the yield in the orchestrations package — + rather than at the ``WorkflowExecutor`` boundary in core, because core has no opinion + about the orchestration's ``AgentResponse`` contract. + + Note: not a subclass of ``AgentRequestInfoExecutor``. The two classes have different + terminal yield contracts (``AgentExecutorResponse`` vs. ``AgentResponse``), and + ``WorkflowContext``'s output type parameter is invariant — so a subclass override would + be type-incompatible. They are siblings sharing only a small ``request_info`` handler. + """ + + @handler + async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None: + """Handle the agent's response and gather additional info from users.""" + await ctx.request_info(agent_response, AgentRequestInfoResponse) + + @response_handler + async def handle_request_info_response( + self, + original_request: AgentExecutorResponse, + response: AgentRequestInfoResponse, + ctx: WorkflowContext[AgentExecutorRequest, AgentResponse], + ) -> None: + """Process the additional info provided by users; yield ``AgentResponse`` on approval.""" + if response.messages: + # User provided additional messages, further iterate on agent response + await ctx.send_message(AgentExecutorRequest(messages=response.messages, should_respond=True)) + else: + # No additional info, approve and surface the wrapped AgentResponse to the parent. + await ctx.yield_output(original_request.agent_response) + + class AgentApprovalExecutor(WorkflowExecutor): """Executor for enabling scenarios requiring agent approval in an orchestration. @@ -122,22 +178,47 @@ class AgentApprovalExecutor(WorkflowExecutor): self, agent: SupportsAgentRun, context_mode: Literal["full", "last_agent", "custom"] | None = None, + *, + allow_direct_output: bool = False, ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. context_mode: The mode for providing context to the agent. + allow_direct_output: When True, the inner agent's response is yielded as the + wrapping workflow's output (rather than forwarded as a message to a + downstream participant). Set this when this executor is the workflow's + terminator — so the user-approved final response surfaces as a workflow + ``output`` event. """ self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description - super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) + super().__init__( + workflow=self._build_workflow(agent, terminal=allow_direct_output), + id=resolve_agent_id(agent), + propagate_request=True, + allow_direct_output=allow_direct_output, + ) - def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: - """Build the internal workflow for the AgentApprovalExecutor.""" - agent_executor = AgentExecutor(agent, context_mode=self._context_mode) - request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") + def _build_workflow(self, agent: SupportsAgentRun, *, terminal: bool) -> Workflow: + """Build the internal workflow for the AgentApprovalExecutor. + + Picks the right ``AgentRequestInfoExecutor`` variant for the role this approval flow + plays in the outer workflow: + + - Intermediate (``terminal=False``): inner workflow yields ``AgentExecutorResponse`` + so the next outer ``AgentExecutor`` participant can consume it via ``from_response``. + - Terminator (``terminal=True``): inner workflow yields ``AgentResponse`` so the outer + workflow's terminal output matches the orchestration contract. + """ + agent_executor = AgentExecutor( + agent, + context_mode=self._context_mode, + ) + request_info_cls = _TerminalAgentRequestInfoExecutor if terminal else AgentRequestInfoExecutor + request_info_executor = request_info_cls(id="agent_request_info_executor") return ( WorkflowBuilder(start_executor=agent_executor) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index bda7f194ab..36d4f23f49 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -2,50 +2,24 @@ """Sequential builder for agent/executor workflows with shared conversation context. -This module provides a high-level, agent-focused API to assemble a sequential -workflow where: -- Participants are provided as SupportsAgentRun or Executor instances via `participants=[...]` -- A shared conversation context (list[Message]) is passed along the chain -- Agents append their assistant messages to the context -- Custom executors can transform or summarize and return a refined context -- The workflow finishes with the final context produced by the last participant +Participants (SupportsAgentRun or Executor instances) run in order, sharing a +conversation along the chain. Agents append their assistant messages; custom executors +transform and return a refined `list[Message]`. -Typical wiring: - input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> - ... -> participantN -> _EndWithConversation +Wiring: input -> _InputToConversation -> participant1 -> ... -> participantN -Notes: -- Participants can mix SupportsAgentRun and Executor objects -- Agents are auto-wrapped by WorkflowBuilder as AgentExecutor (unless already wrapped) -- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[Message] -- Non-agent executors must define a handler that consumes `list[Message]` and sends back - the updated `list[Message]` via their workflow context - -Why include the small internal adapter executors? -- Input normalization ("input-conversation"): ensures the workflow always starts with a - `list[Message]` regardless of whether callers pass a `str`, a single `Message`, - or a list. This keeps the first hop strongly typed and avoids boilerplate in participants. -- Agent response adaptation ("to-conversation:"): agents (via AgentExecutor) - emit `AgentExecutorResponse`. The adapter converts that to a `list[Message]` - using `full_conversation` so original prompts aren't lost when chaining. -- Result output ("end"): yields the final conversation list and the workflow becomes idle - giving a consistent terminal payload shape for both agents and custom executors. - -These adapters are first-class executors by design so they are type-checked at edges, -observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are -deterministic and self-describing (for example, "to-conversation:writer") to reduce event-log -confusion and to mirror how the concurrent builder uses explicit dispatcher/aggregator nodes. +The workflow's final `output` event is the last participant's `yield_output(...)`. For +agent terminators that is an `AgentResponse` (or per-chunk `AgentResponseUpdate`s when +streaming). For custom-executor terminators, the executor itself yields whatever it +produces — by convention an `AgentResponse` so downstream consumers see a uniform shape. """ import logging from collections.abc import Sequence -from typing import Any, Literal +from typing import Literal from agent_framework import Message, SupportsAgentRun -from agent_framework._workflows._agent_executor import ( - AgentExecutor, - AgentExecutorResponse, -) +from agent_framework._workflows._agent_executor import AgentExecutor from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage from agent_framework._workflows._executor import ( @@ -78,34 +52,6 @@ class _InputToConversation(Executor): await ctx.send_message(normalize_messages_input(messages)) -class _EndWithConversation(Executor): - """Terminates the workflow by emitting the final conversation context.""" - - @handler - async def end_with_messages( - self, - conversation: list[Message], - ctx: WorkflowContext[Any, list[Message]], - ) -> None: - """Handler for ending with a list of Message. - - This is used when the last participant is a custom executor. - """ - await ctx.yield_output(list(conversation)) - - @handler - async def end_with_agent_executor_response( - self, - response: AgentExecutorResponse, - ctx: WorkflowContext[Any, list[Message] | None], - ) -> None: - """Handle case where last participant is an agent. - - The agent is wrapped by AgentExecutor and emits AgentExecutorResponse. - """ - await ctx.yield_output(response.full_conversation) - - class SequentialBuilder: r"""High-level builder for sequential agent/executor workflows with shared context. @@ -155,7 +101,9 @@ class SequentialBuilder: chain_only_agent_responses: If True, only agent responses are chained between agents. By default, the full conversation context is passed to the next agent. This also applies to Executor -> Agent transitions if the executor sends `AgentExecutorResponse`. - intermediate_outputs: If True, enables intermediate outputs from agent participants. + intermediate_outputs: If True, every participant's `yield_output` surfaces as a + workflow `output` event in addition to the terminator's. By default (False) only + the last participant's output surfaces. """ self._participants: list[SupportsAgentRun | Executor] = [] self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage @@ -225,7 +173,14 @@ class SequentialBuilder: return self def _resolve_participants(self) -> list[Executor]: - """Resolve participant instances into Executor objects.""" + """Resolve participant instances into Executor objects. + + Wraps `SupportsAgentRun` participants as `AgentExecutor` (or `AgentApprovalExecutor` + when request-info is enabled for that participant). The last participant, when wrapped + as `AgentApprovalExecutor`, is constructed with `allow_direct_output=True` so the + approved response surfaces as the workflow's output event instead of being forwarded + as a message that has nowhere to go. + """ if not self._participants: raise ValueError("No participants provided. Pass participants to the constructor.") @@ -235,8 +190,9 @@ class SequentialBuilder: "last_agent" if self._chain_only_agent_responses else None ) + last_idx = len(participants) - 1 executors: list[Executor] = [] - for p in participants: + for idx, p in enumerate(participants): if isinstance(p, Executor): executors.append(p) elif isinstance(p, SupportsAgentRun): @@ -244,7 +200,13 @@ class SequentialBuilder: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p, context_mode=context_mode)) + executors.append( + AgentApprovalExecutor( + p, + context_mode=context_mode, + allow_direct_output=(idx == last_idx), + ) + ) else: executors.append(AgentExecutor(p, context_mode=context_mode)) else: @@ -256,17 +218,18 @@ class SequentialBuilder: """Build and validate the sequential workflow. Wiring pattern: - - _InputToConversation normalizes the initial input into list[Message] - - For each participant in order: - - If Agent (or AgentExecutor): pass conversation to the agent, then optionally - route through a request info interceptor, then convert response to conversation - via _ResponseToConversation - - Else (custom Executor): pass conversation directly to the executor - - _EndWithConversation yields the final conversation and the workflow becomes idle + - `_InputToConversation` normalizes the initial input into `list[Message]`. + - Each participant runs in order: + - `AgentExecutor`: receives the conversation / `AgentExecutorResponse` and + forwards an `AgentExecutorResponse` downstream. + - Custom `Executor`: receives `list[Message]` and forwards `list[Message]`. + If used as the terminator, it must call `ctx.yield_output(AgentResponse(...))` + instead of `ctx.send_message(...)` — its yield becomes the workflow's output. + - The last participant is registered as the workflow's `output_executor`, so the + terminator's own `yield_output` is the workflow's terminal output (`AgentResponse`, + or per-chunk `AgentResponseUpdate` when streaming). """ - # Internal nodes input_conv = _InputToConversation(id="input-conversation") - end = _EndWithConversation(id="end") # Resolve participants and participant factories to executors participants: list[Executor] = self._resolve_participants() @@ -274,15 +237,12 @@ class SequentialBuilder: builder = WorkflowBuilder( start_executor=input_conv, checkpoint_storage=self._checkpoint_storage, - output_executors=[end] if not self._intermediate_outputs else None, + output_executors=[participants[-1]] if not self._intermediate_outputs else None, ) - # Start of the chain is the input normalizer prior: Executor | SupportsAgentRun = input_conv for p in participants: builder.add_edge(prior, p) prior = p - # Terminate with the final conversation - builder.add_edge(prior, end) return builder.build() diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 7d9a2bc534..e6160f39db 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -1,14 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import Any, cast +from collections.abc import AsyncIterable, Awaitable +from typing import Any, Literal, cast, overload import pytest from agent_framework import ( AgentExecutorRequest, AgentExecutorResponse, AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + BaseAgent, + Content, Executor, Message, + ResponseStream, WorkflowContext, WorkflowRunState, handler, @@ -49,36 +56,26 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None: ConcurrentBuilder(participants=[a, b]) -async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None: - # Three synthetic agent executors +async def test_concurrent_default_aggregator_emits_assistants_only() -> None: + """Default aggregator yields a single AgentResponse with one assistant message per participant. + + The user prompt is intentionally not included — that belongs in the input, not the answer. + """ e1 = _FakeAgentExec("agentA", "Alpha") e2 = _FakeAgentExec("agentB", "Beta") e3 = _FakeAgentExec("agentC", "Gamma") wf = ConcurrentBuilder(participants=[e1, e2, e3]).build() - completed = False - output: list[Message] | None = None - async for ev in wf.run("prompt: hello world", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = cast(list[Message], ev.data) - if completed and output is not None: - break + output_events = [ev for ev in await wf.run("prompt: hello world") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) - assert completed - assert output is not None - messages: list[Message] = output - - # Expect one user message + one assistant message per participant - assert len(messages) == 1 + 3 - assert messages[0].role == "user" - assert "hello world" in messages[0].text - - assistant_texts = {m.text for m in messages[1:]} - assert assistant_texts == {"Alpha", "Beta", "Gamma"} - assert all(m.role == "assistant" for m in messages[1:]) + # Exactly one assistant message per participant; no user prompt. + assert len(response.messages) == 3 + assert all(m.role == "assistant" for m in response.messages) + assert {m.text for m in response.messages} == {"Alpha", "Beta", "Gamma"} async def test_concurrent_custom_aggregator_callback_is_used() -> None: @@ -215,7 +212,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("checkpoint concurrent", stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -236,7 +233,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: ) wf_resume = ConcurrentBuilder(participants=list(resumed_participants), checkpoint_storage=storage).build() - resumed_output: list[Message] | None = None + resumed_output: AgentResponse | None = None async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": resumed_output = ev.data # type: ignore[assignment] @@ -247,8 +244,8 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: break assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages] + assert [m.text for m in resumed_output.messages] == [m.text for m in baseline_output.messages] async def test_concurrent_checkpoint_runtime_only() -> None: @@ -258,7 +255,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf = ConcurrentBuilder(participants=agents).build() - baseline_output: list[Message] | None = None + baseline_output: AgentResponse | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": baseline_output = ev.data # type: ignore[assignment] @@ -278,7 +275,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf_resume = ConcurrentBuilder(participants=resumed_agents).build() - resumed_output: list[Message] | None = None + resumed_output: AgentResponse | None = None async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): @@ -291,7 +288,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None: break assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] + assert [m.role for m in resumed_output.messages] == [m.role for m in baseline_output.messages] async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None: @@ -334,3 +331,46 @@ async def test_concurrent_builder_reusable_after_build_with_participants() -> No assert builder._participants[0] is e1 # type: ignore assert builder._participants[1] is e2 # type: ignore + + +class _EchoAgent(BaseAgent): + """Simple agent that appends a single assistant message with its name.""" + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])]) + + return _run() diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 2118de5ba7..50f58e781a 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -238,18 +238,16 @@ async def test_group_chat_builder_basic_flow() -> None: orchestrator_name="manager", ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("coordinate task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 - assert len(outputs[0]) >= 1 - # Check that both agents contributed - authors = {msg.author_name for msg in outputs[0] if msg.author_name in ["alpha", "beta"]} - assert len(authors) == 2 + # Exactly one terminal `output` event = the orchestrator's completion AgentResponseUpdate + # (mode-aware: streaming yields a single update chunk for the synthesized message). + assert len(updates) == 1 + # The completion message is authored by the orchestrator. + assert updates[0].author_name == "manager" async def test_group_chat_as_agent_accepts_conversation() -> None: @@ -283,18 +281,16 @@ async def test_agent_manager_handles_concatenated_json_output() -> None: orchestrator_agent=manager, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("coordinate task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert outputs - conversation = outputs[-1] - assert any(msg.author_name == "agent" and msg.text == "worker response" for msg in conversation) - assert conversation[-1].author_name == manager.name - assert conversation[-1].text == "concatenated manager final" + assert updates + final_update = updates[-1] + # Terminal update is the orchestrator's completion message. + assert final_update.author_name == manager.name + assert final_update.text == "concatenated manager final" # Comprehensive tests for group chat functionality @@ -400,20 +396,14 @@ class TestGroupChatWorkflow: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Should have terminated due to max_rounds, expect at least one output - assert len(outputs) >= 1 - # The final message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_termination_condition_halts_conversation(self) -> None: """Test that a custom termination condition stops the workflow.""" @@ -433,20 +423,89 @@ class TestGroupChatWorkflow: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] + async for event in workflow.run("test task", stream=True): + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) + + assert updates, "Expected termination to yield output" + # Terminal update is the orchestrator's completion message only. + assert "termination condition" in (updates[-1].text or "").lower() + + async def test_termination_yields_update_in_streaming(self) -> None: + """In streaming mode, the orchestrator's terminal completion surfaces as `AgentResponseUpdate`. + + Mirrors AgentExecutor's mode-aware behavior: streaming workflows produce per-chunk + `AgentResponseUpdate` events; the synthesized termination message is logically a + single chunk, so it should be a single `AgentResponseUpdate`. + """ + + def selector(state: GroupChatState) -> str: + return "agent" + + def termination_condition(conversation: list[Message]) -> bool: + replies = [msg for msg in conversation if msg.role == "assistant" and msg.author_name == "agent"] + return len(replies) >= 2 + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + termination_condition=termination_condition, + selection_func=selector, + ).build() + + terminal: AgentResponseUpdate | None = None async for event in workflow.run("test task", stream=True): if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + terminal = event.data # last output event wins - assert outputs, "Expected termination to yield output" - conversation = outputs[-1] - agent_replies = [msg for msg in conversation if msg.author_name == "agent" and msg.role == "assistant"] - assert len(agent_replies) == 2 - final_output = conversation[-1] - # The orchestrator uses its ID as author_name by default - assert "termination condition" in final_output.text.lower() + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert "termination condition" in (terminal.text or "").lower() + + async def test_termination_yields_response_in_non_streaming(self) -> None: + """In non-streaming mode, the orchestrator's terminal completion surfaces as `AgentResponse`.""" + + def selector(state: GroupChatState) -> str: + return "agent" + + def termination_condition(conversation: list[Message]) -> bool: + replies = [msg for msg in conversation if msg.role == "assistant" and msg.author_name == "agent"] + return len(replies) >= 2 + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + termination_condition=termination_condition, + selection_func=selector, + ).build() + + events = await workflow.run("test task") + outputs = [ev for ev in events if ev.type == "output"] + assert len(outputs) == 1 + assert isinstance(outputs[0].data, AgentResponse) + assert "termination condition" in outputs[0].data.messages[-1].text.lower() + + async def test_max_rounds_yields_update_in_streaming(self) -> None: + """Max-rounds completion in streaming mode surfaces as `AgentResponseUpdate`.""" + + def selector(state: GroupChatState) -> str: + return "agent" + + workflow = GroupChatBuilder( + participants=[StubAgent("agent", "response")], + max_rounds=2, + selection_func=selector, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("test task", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert "maximum number of rounds" in (terminal.text or "").lower() async def test_termination_condition_agent_manager_finalizes(self) -> None: """Test that termination condition with agent orchestrator produces default termination message.""" @@ -459,17 +518,15 @@ class TestGroupChatWorkflow: orchestrator_agent=manager, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert outputs, "Expected termination to yield output" - conversation = outputs[-1] - assert conversation[-1].text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE - assert conversation[-1].author_name == manager.name + assert updates, "Expected termination to yield output" + final_update = updates[-1] + assert final_update.text == BaseGroupChatOrchestrator.TERMINATION_CONDITION_MET_MESSAGE + assert final_update.author_name == manager.name async def test_unknown_participant_error(self) -> None: """Test that unknown participant selection raises error.""" @@ -505,14 +562,12 @@ class TestCheckpointing: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 # Should complete normally + assert len(updates) == 1 # Should complete normally class TestConversationHandling: @@ -546,14 +601,12 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test string", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 async def test_handle_chat_message_input(self) -> None: """Test handling Message input directly.""" @@ -569,14 +622,12 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run(task_message, stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 async def test_handle_conversation_list_input(self) -> None: """Test handling conversation list preserves context.""" @@ -595,14 +646,12 @@ class TestConversationHandling: workflow = GroupChatBuilder(participants=[agent], max_rounds=1, selection_func=selector).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run(conversation, stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - assert len(outputs) == 1 + assert len(updates) == 1 class TestRoundLimitEnforcement: @@ -625,20 +674,14 @@ class TestRoundLimitEnforcement: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Should have at least one output (the round limit message) - assert len(outputs) >= 1 - # The last message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_round_limit_in_ingest_participant_message(self) -> None: """Test round limit enforcement after participant response.""" @@ -658,20 +701,14 @@ class TestRoundLimitEnforcement: selection_func=selector, ).build() - outputs: list[list[Message]] = [] + updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test", stream=True): - if event.type == "output": - data = event.data - if isinstance(data, list): - outputs.append(cast(list[Message], data)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) - # Should have at least one output (the round limit message) - assert len(outputs) >= 1 - # The last message in the conversation should be about round limit - conversation = outputs[-1] - assert len(conversation) >= 1 - final_output = conversation[-1] - assert "maximum number of rounds" in final_output.text.lower() + # Exactly one terminal output event = orchestrator's max-rounds completion update. + assert len(updates) == 1 + assert "maximum number of rounds" in (updates[0].text or "").lower() async def test_group_chat_checkpoint_runtime_only() -> None: @@ -684,17 +721,17 @@ async def test_group_chat_checkpoint_runtime_only() -> None: wf = GroupChatBuilder(participants=[agent_a, agent_b], max_rounds=2, selection_func=selector).build() - baseline_output: list[Message] | None = None + baseline_update: AgentResponseUpdate | None = None async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): - if ev.type == "output": - baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore + if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): + baseline_update = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert baseline_output is not None + assert baseline_update is not None checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" @@ -720,17 +757,17 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: checkpoint_storage=buildtime_storage, selection_func=selector, ).build() - baseline_output: list[Message] | None = None + baseline_update: AgentResponseUpdate | None = None async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True): - if ev.type == "output": - baseline_output = cast(list[Message], ev.data) if isinstance(ev.data, list) else None # type: ignore + if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): + baseline_update = ev.data if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert baseline_output is not None + assert baseline_update is not None buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) @@ -974,14 +1011,11 @@ async def test_group_chat_with_orchestrator_factory_returning_chat_agent(): outputs.append(event) assert len(outputs) == 1 - # The DynamicManagerAgent terminates after second call with final_message - final_messages = outputs[0].data - assert isinstance(final_messages, list) - assert any( - msg.text == "dynamic manager final" - for msg in cast(list[Message], final_messages) - if msg.author_name == "dynamic_manager" - ) + # Streaming mode: terminal yield is AgentResponseUpdate. The DynamicManagerAgent + # terminates after second call with final_message. + final_update = outputs[0].data + assert isinstance(final_update, AgentResponseUpdate) + assert final_update.text == "dynamic manager final" def test_group_chat_with_orchestrator_factory_returning_base_orchestrator(): diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index a512d9b9df..33eed34406 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -9,6 +9,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework import ( Agent, + AgentResponse, + AgentResponseUpdate, ChatResponse, ChatResponseUpdate, Content, @@ -856,10 +858,15 @@ async def test_autonomous_mode_yields_output_without_user_request(): outputs = [ev for ev in events if ev.type == "output"] assert outputs, "Autonomous mode should yield a workflow output" - final_conversation = outputs[-1].data - assert isinstance(final_conversation, list) - conversation_list = cast(list[Message], final_conversation) - assert any(msg.role == "assistant" and (msg.text or "").startswith("specialist reply") for msg in conversation_list) + # Per-agent activity surfaces as `output` events from each HandoffAgentExecutor as they + # speak. Handoff has no orchestrator that produces a separate "answer" — the conversation + # IS the result. In streaming mode payloads are AgentResponseUpdate; combined text should + # contain the specialist's reply. + payloads = [ev.data for ev in outputs if isinstance(ev.data, (AgentResponse, AgentResponseUpdate))] + combined = " ".join( + getattr(p, "text", None) or " ".join(m.text for m in getattr(p, "messages", [])) for p in payloads + ) + assert "specialist reply" in combined async def test_autonomous_mode_resumes_user_input_on_turn_limit(): @@ -923,14 +930,10 @@ async def test_handoff_async_termination_condition() -> None: stream=True, responses={requests[-1].request_id: [Message(role="user", contents=["Second user message"])]} ) ) - outputs = [ev for ev in events if ev.type == "output"] - assert len(outputs) == 1 - - final_conversation = outputs[0].data - assert isinstance(final_conversation, list) - final_conv_list = cast(list[Message], final_conversation) - user_messages = [msg for msg in final_conv_list if msg.role == "user"] - assert len(user_messages) == 2 + # Resume run terminates without further agent activity once the second user message + # satisfies the termination condition. The workflow returns to idle cleanly. + idle_states = [ev for ev in events if ev.type == "status" and ev.state == WorkflowRunState.IDLE] + assert idle_states, "Workflow should become idle after termination" assert termination_call_count > 0 @@ -990,8 +993,9 @@ async def test_handoff_terminates_without_request_info_when_latest_response_meet outputs = [event for event in events if event.type == "output"] assert outputs - conversation_outputs = [event for event in outputs if isinstance(event.data, list)] - assert len(conversation_outputs) == 1 + # Per-agent activity surfaces as output events (AgentResponseUpdate in streaming mode). + agent_payloads = [event for event in outputs if isinstance(event.data, (AgentResponse, AgentResponseUpdate))] + assert len(agent_payloads) >= 1 async def test_tool_choice_preserved_from_agent_config(): diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index b87de8c6f4..0389fad94e 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -190,24 +190,82 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: assert isinstance(workflow, Workflow) - outputs: list[Message] = [] + updates: list[AgentResponseUpdate] = [] orchestrator_event_count = 0 async for event in workflow.run("compose summary", stream=True): - if event.type == "output": - msg = event.data - if isinstance(msg, list): - outputs.extend(cast(list[Message], msg)) + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + updates.append(event.data) elif event.type == "magentic_orchestrator": orchestrator_event_count += 1 - assert outputs, "Expected a final output message" - assert len(outputs) >= 1 - final = outputs[-1] + assert updates, "Expected a final output update" + final = updates[-1] assert final.text == manager.FINAL_ANSWER assert final.author_name == manager.name assert orchestrator_event_count > 0, "Expected orchestrator events to be emitted" +async def test_magentic_final_answer_yields_update_in_streaming() -> None: + """In streaming mode, Magentic's manager final-answer surfaces as `AgentResponseUpdate`. + + Mirrors AgentExecutor's mode-aware behavior: streaming workflows produce per-chunk + `AgentResponseUpdate` events; the synthesized final answer is logically a single chunk, + so it surfaces as a single `AgentResponseUpdate`. + """ + manager = FakeManager() + workflow = MagenticBuilder( + participants=[StubAgent(manager.next_speaker_name, "first draft")], + manager=manager, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("compose summary", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + assert terminal.text == manager.FINAL_ANSWER + assert terminal.author_name == manager.name + + +async def test_magentic_final_answer_yields_response_in_non_streaming() -> None: + """In non-streaming mode, Magentic's manager final-answer surfaces as `AgentResponse`.""" + manager = FakeManager() + workflow = MagenticBuilder( + participants=[StubAgent(manager.next_speaker_name, "first draft")], + manager=manager, + ).build() + + events = await workflow.run("compose summary") + outputs = [ev for ev in events if ev.type == "output"] + assert len(outputs) == 1 + assert isinstance(outputs[0].data, AgentResponse) + assert outputs[0].data.messages[-1].text == manager.FINAL_ANSWER + + +async def test_magentic_limit_termination_yields_update_in_streaming() -> None: + """In streaming mode, Magentic's round-limit termination surfaces as `AgentResponseUpdate`.""" + manager = FakeManager(max_round_count=1) + workflow = MagenticBuilder( + participants=[DummyExec(name=manager.next_speaker_name)], + manager=manager, + ).build() + + terminal: AgentResponseUpdate | None = None + async for event in workflow.run("round limit test", stream=True): + if event.type == "output": + terminal = event.data + + assert isinstance(terminal, AgentResponseUpdate), ( + f"Expected AgentResponseUpdate in streaming mode, got {type(terminal).__name__}" + ) + # Either the final answer OR the round-limit termination message — both are valid terminal states + # for max_round_count=1; the precise one depends on FakeManager's progression. + assert terminal.text + + async def test_magentic_as_agent_does_not_accept_conversation() -> None: manager = FakeManager() writer = StubAgent(manager.next_speaker_name, "summary response") @@ -250,7 +308,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert isinstance(req_event.data, MagenticPlanReviewRequest) completed = False - output: list[Message] | None = None + output: AgentResponseUpdate | None = None async for ev in wf.run(stream=True, responses={req_event.request_id: req_event.data.approve()}): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True @@ -261,8 +319,8 @@ async def test_magentic_workflow_plan_review_approval_to_completion(): assert completed assert output is not None - assert isinstance(output, list) - assert all(isinstance(msg, Message) for msg in output) + # Streaming mode: terminal output is AgentResponseUpdate. + assert isinstance(output, AgentResponseUpdate) async def test_magentic_plan_review_with_revise(): @@ -333,14 +391,12 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result(): None, ) assert idle_status is not None - # Check that we got workflow output via WorkflowEvent with type "output" + # Streaming mode: terminal output is AgentResponseUpdate. output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None data = output_event.data - assert isinstance(data, list) - assert len(data) > 0 # type: ignore - assert data[-1].role == "assistant" # type: ignore - assert all(isinstance(msg, Message) for msg in data) # type: ignore + assert isinstance(data, AgentResponseUpdate) + assert data.role == "assistant" async def test_magentic_checkpoint_resume_round_trip(): @@ -578,7 +634,7 @@ async def _collect_agent_responses_setup(participant: SupportsAgentRun) -> list[ # Run a bounded stream to allow one invoke and then completion events: list[WorkflowEvent] = [] - async for ev in wf.run("task", stream=True): # plan review disabled + async for ev in wf.run("task", stream=True): events.append(ev) # Capture streaming updates (type="output" with AgentResponseUpdate data) if ev.type == "output" and isinstance(ev.data, AgentResponseUpdate): @@ -753,11 +809,9 @@ async def test_magentic_stall_and_reset_reach_limits(): assert idle_status is not None output_event = next((e for e in events if e.type == "output"), None) assert output_event is not None - assert isinstance(output_event.data, list) - assert all(isinstance(msg, Message) for msg in output_event.data) # type: ignore - assert len(output_event.data) > 0 # type: ignore - assert output_event.data[-1].text is not None # type: ignore - assert output_event.data[-1].text == "Workflow terminated due to reaching maximum reset count." # type: ignore + # Streaming mode: terminal output is AgentResponseUpdate. + assert isinstance(output_event.data, AgentResponseUpdate) + assert output_event.data.text == "Workflow terminated due to reaching maximum reset count." async def test_magentic_checkpoint_runtime_only() -> None: diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 0f000ef254..d119a20120 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -22,6 +22,7 @@ from agent_framework import ( ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage from agent_framework.orchestrations import SequentialBuilder +from typing_extensions import Never class _EchoAgent(BaseAgent): @@ -67,16 +68,20 @@ class _EchoAgent(BaseAgent): return _run() -class _SummarizerExec(Executor): - """Custom executor that summarizes by appending a short assistant message.""" +class _SummarizerTerminator(Executor): + """Custom-executor terminator that yields a synthesized summary as the workflow's final answer.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: + async def summarize( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, AgentResponse], + ) -> None: conversation = agent_response.full_conversation or [] user_texts = [m.text for m in conversation if m.role == "user"] agents = [m.author_name or m.role for m in conversation if m.role == "assistant"] summary = Message("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"]) - await ctx.send_message(list(conversation) + [summary]) + await ctx.yield_output(AgentResponse(messages=[summary])) class _InvalidExecutor(Executor): @@ -98,58 +103,91 @@ def test_sequential_builder_validation_rejects_invalid_executor() -> None: SequentialBuilder(participants=[_EchoAgent(id="agent1", name="A1"), _InvalidExecutor(id="invalid")]).build() -async def test_sequential_agents_append_to_context() -> None: +async def test_sequential_streaming_yields_only_last_agent_updates() -> None: + """Streaming mode surfaces only the last agent's AgentResponseUpdate chunks as outputs. + + Intermediate agents do NOT emit `output` events; only the last agent (the workflow's + output_executor) emits chunks of the final answer. + """ a1 = _EchoAgent(id="agent1", name="A1") a2 = _EchoAgent(id="agent2", name="A2") wf = SequentialBuilder(participants=[a1, a2]).build() completed = False - output: list[Message] | None = None + update_events: list[AgentResponseUpdate] = [] async for ev in wf.run("hello sequential", stream=True): if ev.type == "status" and ev.state == WorkflowRunState.IDLE: completed = True elif ev.type == "output": - output = ev.data # type: ignore[assignment] - if completed and output is not None: + update_events.append(ev.data) # type: ignore[arg-type] + if completed: break assert completed - assert output is not None - assert isinstance(output, list) - msgs: list[Message] = output - assert len(msgs) == 3 - assert msgs[0].role == "user" and "hello sequential" in msgs[0].text - assert msgs[1].role == "assistant" and (msgs[1].author_name == "A1" or True) - assert msgs[2].role == "assistant" and (msgs[2].author_name == "A2" or True) - assert "A1 reply" in msgs[1].text - assert "A2 reply" in msgs[2].text + # Only the last agent's streaming chunks surface as `output` events. + assert update_events, "Expected at least one streaming update from the last agent" + for upd in update_events: + assert isinstance(upd, AgentResponseUpdate) + combined_text = "".join(u.text for u in update_events if hasattr(u, "text")) + assert "A2 reply" in combined_text + assert "A1 reply" not in combined_text + + +async def test_sequential_non_streaming_yields_only_last_agent_response() -> None: + """Non-streaming mode emits a single `output` event with the last agent's AgentResponse.""" + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = SequentialBuilder(participants=[a1, a2]).build() + + output_events = [ev for ev in await wf.run("hello sequential") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert all(m.role == "assistant" for m in response.messages) + combined = " ".join(m.text for m in response.messages) + assert "A2 reply" in combined + assert "A1 reply" not in combined + + +async def test_sequential_as_agent_returns_only_last_agent_response() -> None: + """`workflow.as_agent().run(prompt)` returns ONLY the last agent's messages — not the user + input or earlier agents' replies. This is the core fix for the orchestration-as-agent + output contract.""" + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + agent = SequentialBuilder(participants=[a1, a2]).build().as_agent() + response = await agent.run("hello as_agent") + + assert isinstance(response, AgentResponse) + # Only the last agent's reply — no user prompt, no agent1 messages. + combined = " ".join(m.text for m in response.messages) + assert "A2 reply" in combined + assert "A1 reply" not in combined + assert "hello as_agent" not in combined async def test_sequential_with_custom_executor_summary() -> None: + """A custom-executor terminator yields its own AgentResponse — that becomes the workflow output. + + Custom executors used as the terminator must call `ctx.yield_output(AgentResponse(...))` + directly (rather than `ctx.send_message(list[Message])` like an intermediate executor would), + because the terminator IS the workflow's output executor. + """ a1 = _EchoAgent(id="agent1", name="A1") - summarizer = _SummarizerExec(id="summarizer") + summarizer = _SummarizerTerminator(id="summarizer") wf = SequentialBuilder(participants=[a1, summarizer]).build() - completed = False - output: list[Message] | None = None - async for ev in wf.run("topic X", stream=True): - if ev.type == "status" and ev.state == WorkflowRunState.IDLE: - completed = True - elif ev.type == "output": - output = ev.data - if completed and output is not None: - break - - assert completed - assert output is not None - msgs: list[Message] = output - # Expect: [user, A1 reply, summary] - assert len(msgs) == 3 - assert msgs[0].role == "user" - assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text - assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:") + output_events = [ev for ev in await wf.run("topic X") if ev.type == "output"] + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert len(response.messages) == 1 + assert response.messages[0].role == "assistant" + assert response.messages[0].text.startswith("Summary of users:") async def test_sequential_checkpoint_resume_round_trip() -> None: @@ -158,14 +196,14 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build() - baseline_output: list[Message] | None = None + baseline_updates: list[AgentResponseUpdate] = [] async for ev in wf.run("checkpoint sequential", stream=True): if ev.type == "output": - baseline_output = ev.data # type: ignore[assignment] + baseline_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state == WorkflowRunState.IDLE: break - assert baseline_output is not None + assert baseline_updates checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints @@ -175,19 +213,20 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build() - resumed_output: list[Message] | None = None + resumed_updates: list[AgentResponseUpdate] = [] async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True): if ev.type == "output": - resumed_output = ev.data # type: ignore[assignment] + resumed_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert resumed_updates + baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text")) + resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text")) + assert baseline_text == resumed_text async def test_sequential_checkpoint_runtime_only() -> None: @@ -197,14 +236,14 @@ async def test_sequential_checkpoint_runtime_only() -> None: agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(agents)).build() - baseline_output: list[Message] | None = None + baseline_updates: list[AgentResponseUpdate] = [] async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True): if ev.type == "output": - baseline_output = ev.data # type: ignore[assignment] + baseline_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state == WorkflowRunState.IDLE: break - assert baseline_output is not None + assert baseline_updates checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints @@ -214,21 +253,22 @@ async def test_sequential_checkpoint_runtime_only() -> None: resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents)).build() - resumed_output: list[Message] | None = None + resumed_updates: list[AgentResponseUpdate] = [] async for ev in wf_resume.run( checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True ): if ev.type == "output": - resumed_output = ev.data # type: ignore[assignment] + resumed_updates.append(ev.data) # type: ignore[arg-type] if ev.type == "status" and ev.state in ( WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ): break - assert resumed_output is not None - assert [m.role for m in resumed_output] == [m.role for m in baseline_output] - assert [m.text for m in resumed_output] == [m.text for m in baseline_output] + assert resumed_updates + baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text")) + resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text")) + assert baseline_text == resumed_text async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None: @@ -390,3 +430,47 @@ async def test_chain_only_agent_responses_three_agents() -> None: # a3 should see only A2's reply assert len(a3.last_messages) == 1 assert a3.last_messages[0].role == "assistant" and "A2 reply" in (a3.last_messages[0].text or "") + + +# --------------------------------------------------------------------------- +# with_request_info tests +# --------------------------------------------------------------------------- + + +async def test_sequential_request_info_last_participant_emits_output() -> None: + """When the last participant is wrapped via with_request_info(), the workflow + still emits a terminal output event after approval. + + This exercises the _EndWithConversation.end_with_agent_executor_response path + that converts the AgentApprovalExecutor's forwarded AgentExecutorResponse into + the workflow's final AgentResponse output. + """ + from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse + + a1 = _EchoAgent(id="agent1", name="A1") + a2 = _EchoAgent(id="agent2", name="A2") + + wf = SequentialBuilder(participants=[a1, a2]).with_request_info().build() + + # First run: collect request_info events for both agents + request_events: list[Any] = [] + async for ev in wf.run("hello with approval", stream=True): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + + # Approve each agent in sequence until the workflow completes + while request_events: + responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events} + request_events = [] + output_events: list[Any] = [] + async for ev in wf.run(stream=True, responses=responses): + if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse): + request_events.append(ev) + elif ev.type == "output": + output_events.append(ev) + + # The workflow must produce a terminal output with the last agent's response. + assert len(output_events) == 1 + response = output_events[0].data + assert isinstance(response, AgentResponse) + assert any("A2 reply" in m.text for m in response.messages) diff --git a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py index 52de975173..120bd448aa 100644 --- a/python/samples/03-workflows/agents/sequential_workflow_as_agent.py +++ b/python/samples/03-workflows/agents/sequential_workflow_as_agent.py @@ -26,8 +26,8 @@ Note on internal adapters: You can safely ignore them when focusing on agent progress. Prerequisites: -- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. -- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name. +- FOUNDRY_PROJECT_ENDPOINT must be set to the Azure Foundry project endpoint. +- FOUNDRY_MODEL must be set to the model name for the Foundry chat client. """ @@ -68,28 +68,18 @@ async def main() -> None: """ Sample Output: - ===== Final Conversation ===== + ===== Conversation ===== ------------------------------------------------------------ - 01 [user] - Write a tagline for a budget-friendly eBike. - ------------------------------------------------------------ - 02 [writer] - Ride farther, spend less—your affordable eBike adventure starts here. - ------------------------------------------------------------ - 03 [reviewer] - This tagline clearly communicates affordability and the benefit of extended travel, making it - appealing to budget-conscious consumers. It has a friendly and motivating tone, though it could - be slightly shorter for more punch. Overall, a strong and effective suggestion! - - ===== as_agent() Conversation ===== - ------------------------------------------------------------ - 01 [writer] - Go electric, save big—your affordable ride awaits! - ------------------------------------------------------------ - 02 [reviewer] + 01 [reviewer] Catchy and straightforward! The tagline clearly emphasizes both the electric aspect and the affordability of the eBike. It's inviting and actionable. For even more impact, consider making it slightly shorter: "Go electric, save big." Overall, this is an effective and appealing suggestion for a budget-friendly eBike. + + Note: + `workflow.as_agent()` returns ONLY the final agent's response (the "answer") — the prior agents' work + is not included in the response. To observe intermediate agents while running as an agent, build with + `SequentialBuilder(participants=[...], intermediate_outputs=True)`; the intermediate replies are then + surfaced as `data` events and merged into the AgentResponse. """ diff --git a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py index a4fb2d602b..b907c24cdb 100644 --- a/python/samples/03-workflows/orchestrations/sequential_custom_executors.py +++ b/python/samples/03-workflows/orchestrations/sequential_custom_executors.py @@ -2,11 +2,11 @@ import asyncio import os -from typing import Any from agent_framework import ( Agent, AgentExecutorResponse, + AgentResponse, Executor, Message, WorkflowContext, @@ -16,6 +16,7 @@ from agent_framework.foundry import FoundryChatClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv +from typing_extensions import Never # Load environment variables from .env file load_dotenv() @@ -25,13 +26,14 @@ Sample: Sequential workflow mixing agents and a custom summarizer executor This demonstrates how SequentialBuilder chains participants with a shared conversation context (list[Message]). An agent produces content; a custom -executor appends a compact summary to the conversation. The workflow completes -after all participants have executed in sequence, and the final output contains -the complete conversation. +executor synthesizes a compact summary and yields it as the workflow's terminal +output. Custom executor contract: -- Provide at least one @handler accepting AgentExecutorResponse and a WorkflowContext[list[Message]] -- Emit the updated conversation via ctx.send_message([...]) +- Intermediate custom executors: handle the message type from the prior participant + and forward `list[Message]` via `ctx.send_message(...)` for the next participant. +- Terminator custom executors: handle the message type from the prior participant and + yield the workflow's final answer as an `AgentResponse` via `ctx.yield_output(...)`. Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. @@ -41,27 +43,29 @@ Prerequisites: class Summarizer(Executor): - """Simple summarizer: consumes full conversation and appends an assistant summary.""" + """Terminator custom executor: synthesizes a one-line summary as the workflow's final answer.""" @handler - async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[Message]]) -> None: - """Append a summary message to a copy of the full conversation. + async def summarize( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, AgentResponse], + ) -> None: + """Yield a terminal AgentResponse containing the summary. - Note: A custom executor must be able to handle the message type from the prior participant, and produce - the message type expected by the next participant. In this case, the prior participant is an agent thus - the input is AgentExecutorResponse (an agent will be wrapped in an AgentExecutor, which produces - `AgentExecutorResponse`). If the next participant is also an agent or this is the final participant, - the output must be `list[Message]`. + The prior participant is an agent, which is wrapped in an `AgentExecutor` that + produces `AgentExecutorResponse`. As the last participant in the sequential workflow, + this executor calls `ctx.yield_output(AgentResponse(...))` so its output becomes the + workflow's terminal output (rather than being forwarded to a downstream participant). """ if not agent_response.full_conversation: - await ctx.send_message([Message("assistant", ["No conversation to summarize."])]) + await ctx.yield_output(AgentResponse(messages=[Message("assistant", ["No conversation to summarize."])])) return users = sum(1 for m in agent_response.full_conversation if m.role == "user") assistants = sum(1 for m in agent_response.full_conversation if m.role == "assistant") summary = Message("assistant", [f"Summary -> users:{users} assistants:{assistants}"]) - final_conversation = list(agent_response.full_conversation) + [summary] - await ctx.send_message(final_conversation) + await ctx.yield_output(AgentResponse(messages=[summary])) async def main() -> None: @@ -81,33 +85,20 @@ async def main() -> None: summarizer = Summarizer(id="summarizer") workflow = SequentialBuilder(participants=[content, summarizer]).build() - # 3) Run workflow and extract final conversation + # 3) Run workflow and extract the final summary events = await workflow.run("Explain the benefits of budget eBikes for commuters.") outputs = events.get_outputs() if outputs: - print("===== Final Conversation =====") - messages: list[Message] | Any = outputs[0] - for i, msg in enumerate(messages, start=1): - name = msg.author_name or ("assistant" if msg.role == "assistant" else "user") - print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}") + print("===== Final Summary =====") + final: AgentResponse = outputs[0] + for msg in final.messages: + print(msg.text) """ Sample Output: - ------------------------------------------------------------ - 01 [user] - Explain the benefits of budget eBikes for commuters. - ------------------------------------------------------------ - 02 [content] - Budget eBikes offer commuters an affordable, eco-friendly alternative to cars and public transport. - Their electric assistance reduces physical strain and allows riders to cover longer distances quickly, - minimizing travel time and fatigue. Budget models are low-cost to maintain and operate, making them accessible - for a wider range of people. Additionally, eBikes help reduce traffic congestion and carbon emissions, - supporting greener urban environments. Overall, budget eBikes provide cost-effective, efficient, and - sustainable transportation for daily commuting needs. - ------------------------------------------------------------ - 03 [assistant] + ===== Final Summary ===== Summary -> users:1 assistants:1 """ diff --git a/python/uv.lock b/python/uv.lock index 4e60e5cfd6..8fb84afa44 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -585,7 +585,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=0.2.1,>=0.2.1" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]]