Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions infra/scripts/agent_scripts/01_create_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from azure.identity.aio import AzureCliCredential
from azure.ai.projects.models import (
PromptAgentDefinition,
AzureAISearchAgentTool,
AzureAISearchTool,
FunctionTool,
AzureAISearchToolResource,
AISearchIndexResource,
Expand Down Expand Up @@ -115,7 +115,7 @@ async def main():
}
),
# Azure AI Search - built-in service tool (no client implementation needed)
AzureAISearchAgentTool(
AzureAISearchTool(
azure_ai_search=AzureAISearchToolResource(
indexes=[
AISearchIndexResource(
Expand Down
2 changes: 1 addition & 1 deletion infra/scripts/agent_scripts/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
aiohttp==3.13.4
azure-identity==1.25.2
azure-ai-projects==2.0.0b3
azure-ai-projects==2.1.0
18 changes: 6 additions & 12 deletions infra/scripts/index_scripts/03_cu_process_data_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

# Suppress informational warnings from agent_framework about runtime
# tool/structured_output overrides not being supported by AzureAIClient.
logging.getLogger("agent_framework.azure").setLevel(logging.ERROR)
logging.getLogger("agent_framework.foundry").setLevel(logging.ERROR)

import pandas as pd
import pyodbc
Expand All @@ -29,7 +29,7 @@
from azure.search.documents.indexes import SearchIndexClient
from azure.storage.filedatalake import DataLakeServiceClient

from agent_framework.azure import AzureAIProjectAgentProvider
from agent_framework_foundry import FoundryAgent

from content_understanding_client import AzureContentUnderstandingClient, sanitize_cu_output

Expand Down Expand Up @@ -514,11 +514,8 @@ async def call_topic_mining_agent(topics_str1):
AsyncAzureCliCredential(process_timeout=30) as async_cred,
AIProjectClient(endpoint=AI_PROJECT_ENDPOINT, credential=async_cred) as project_client,
):
# Create provider for agent management
provider = AzureAIProjectAgentProvider(project_client=project_client)

# Get agent using provider
agent = await provider.get_agent(name=TOPIC_MINING_AGENT_NAME)
# Create agent using FoundryAgent
agent = FoundryAgent(project_client=project_client, agent_name=TOPIC_MINING_AGENT_NAME)

# Query with the topics string
query = f"Analyze these conversation topics and identify distinct categories: {topics_str1}"
Expand Down Expand Up @@ -561,11 +558,8 @@ async def map_all_topics():
AsyncAzureCliCredential(process_timeout=30) as async_cred,
AIProjectClient(endpoint=AI_PROJECT_ENDPOINT, credential=async_cred) as project_client,
):
# Create provider for agent management
provider = AzureAIProjectAgentProvider(project_client=project_client)

# Get agent using provider
agent = await provider.get_agent(name=TOPIC_MAPPING_AGENT_NAME)
# Create agent using FoundryAgent
agent = FoundryAgent(project_client=project_client, agent_name=TOPIC_MAPPING_AGENT_NAME)

# Process all rows using the same agent instance
for _, row in df_processed_data.iterrows():
Expand Down
18 changes: 6 additions & 12 deletions infra/scripts/index_scripts/04_cu_process_custom_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

# Suppress informational warnings from agent_framework about runtime
# tool/structured_output overrides not being supported by AzureAIClient.
logging.getLogger("agent_framework.azure").setLevel(logging.ERROR)
logging.getLogger("agent_framework.foundry").setLevel(logging.ERROR)

import pandas as pd
import pyodbc
Expand All @@ -43,7 +43,7 @@
)
from azure.storage.filedatalake import DataLakeServiceClient

from agent_framework.azure import AzureAIProjectAgentProvider
from agent_framework_foundry import FoundryAgent

from content_understanding_client import AzureContentUnderstandingClient, sanitize_cu_output

Expand Down Expand Up @@ -610,11 +610,8 @@ async def call_topic_mining_agent(topics_str1):
AsyncAzureCliCredential(process_timeout=30) as async_cred,
AIProjectClient(endpoint=AI_PROJECT_ENDPOINT, credential=async_cred) as project_client,
):
# Create provider for agent management
provider = AzureAIProjectAgentProvider(project_client=project_client)

# Get agent using provider
agent = await provider.get_agent(name=TOPIC_MINING_AGENT_NAME)
# Create agent using FoundryAgent
agent = FoundryAgent(project_client=project_client, agent_name=TOPIC_MINING_AGENT_NAME)

# Query with the topics string
query = f"Analyze these conversation topics and identify distinct categories: {topics_str1}"
Expand Down Expand Up @@ -657,11 +654,8 @@ async def map_all_topics():
AsyncAzureCliCredential(process_timeout=30) as async_cred,
AIProjectClient(endpoint=AI_PROJECT_ENDPOINT, credential=async_cred) as project_client,
):
# Create provider for agent management
provider = AzureAIProjectAgentProvider(project_client=project_client)

# Get agent using provider
agent = await provider.get_agent(name=TOPIC_MAPPING_AGENT_NAME)
# Create agent using FoundryAgent
agent = FoundryAgent(project_client=project_client, agent_name=TOPIC_MAPPING_AGENT_NAME)

# Process all rows using the same agent instance
for _, row in df_processed_data.iterrows():
Expand Down
8 changes: 4 additions & 4 deletions infra/scripts/index_scripts/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
azure-storage-file-datalake==12.23.0
openai==2.24.0
azure-ai-projects==2.0.0b3
azure-ai-agents==1.2.0b5
agent-framework-core==1.0.0rc2
agent-framework-azure-ai==1.0.0rc2
azure-ai-projects==2.1.0
azure-ai-inference==1.0.0b9
agent-framework-core==1.3.0
agent-framework-foundry==1.3.0
pypdf==6.10.2
tiktoken==0.12.0
azure-identity==1.25.2
Expand Down
9 changes: 5 additions & 4 deletions src/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ aiohttp==3.13.5
# Azure Services
azure-identity==1.25.3
azure-search-documents==11.6.0
azure-ai-projects==2.0.0b3
azure-ai-agents==1.2.0b5
agent-framework-core==1.0.0rc2
agent-framework-azure-ai==1.0.0rc2
azure-ai-projects==2.1.0
azure-ai-inference==1.0.0b9
agent-framework-core==1.3.0
agent-framework-foundry==1.3.0
agent-framework-openai==1.3.0
azure-cosmos==4.15.0

# Additional utilities
Expand Down
1 change: 1 addition & 0 deletions src/api/services/_patches/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Runtime patches applied to third-party packages used by this app."""
175 changes: 175 additions & 0 deletions src/api/services/_patches/agent_framework_search_citations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Restore Azure AI Search ``get_url`` enrichment on streaming citations.

Pre-GA ``agent-framework-azure-ai==1.0.0rc2`` enriched ``url_citation``
streaming annotations with a per-document REST URL exposed under
``additional_properties.get_url``. That subclass was removed when the
``azure-ai`` package was retired at GA.

In the GA ``agent_framework_openai._chat_client._parse_chunk_from_openai``:

1. ``response.azure_ai_search_call_output.done`` events fall through to
the default ``case _:`` debug log, so the ``output.get_urls[]`` array
carrying per-document REST URLs is silently dropped.
2. ``url_citation`` annotations (added by upstream PR #5071) emit only
``title`` and ``url`` (the search-service root URL).

This patch wraps that method to:

1. Cache ``get_urls`` per-stream when seeing the search-call-output event.
2. Inject ``additional_properties.get_url`` on ``url_citation`` annotations
using ``annotation_index`` as the lookup key, matching the pre-GA
contract that the citation extraction in ``chat_service.py`` reads.

Tracking upstream: https://github.com/microsoft/agent-framework/issues/5995
Safe to remove once upstream ports the ``get_url`` enrichment into
``agent_framework_openai`` or ``agent_framework_foundry``.
"""

from __future__ import annotations

import logging
import re
from typing import Any

_DOC_INDEX_RE = re.compile(r"^doc_(\d+)$")

logger = logging.getLogger(__name__)

_PATCH_APPLIED = False
_CACHE_ATTR = "_kmsa_search_get_urls_cache"
_TARGET_METHOD = "_parse_chunk_from_openai"
_TARGET_CLASS = "RawOpenAIChatClient"
_UPSTREAM_ISSUE = "https://github.com/microsoft/agent-framework/issues/5995"


def apply() -> None:
"""Idempotently patch RawOpenAIChatClient._parse_chunk_from_openai.

Safe to call multiple times; the second call is a no-op.
Logs a warning (does not raise) if upstream has renamed the target,
so app startup still succeeds with degraded citations.
"""
global _PATCH_APPLIED
if _PATCH_APPLIED:
return

try:
from agent_framework_openai import _chat_client as _cc
except ImportError:
logger.warning(
"agent_framework_openai not installed; "
"Azure AI Search citation patch skipped"
)
return

target_cls = getattr(_cc, _TARGET_CLASS, None)
if target_cls is None or not hasattr(target_cls, _TARGET_METHOD):
logger.warning(
"agent-framework upgrade broke citation patch: %s.%s no longer exists. "
"Per-document URLs (get_url) will be missing on Azure AI Search "
"citations. See %s",
_TARGET_CLASS, _TARGET_METHOD, _UPSTREAM_ISSUE,
)
return

_original = getattr(target_cls, _TARGET_METHOD)

def _patched(self: Any, event: Any, *args: Any, **kwargs: Any) -> Any:
event_type = getattr(event, "type", None)

# Reset per-stream cache so back-to-back requests on the same client
# instance don't cross-pollute citation enrichment.
if event_type in ("response.created", "response.in_progress"):
setattr(self, _CACHE_ATTR, [])

# Capture get_urls from azure_ai_search_call_output items on .done.
# The .added event for this item has output='[]'; the .done event
# carries the actual documents + get_urls.
if event_type == "response.output_item.done":
try:
done_item = getattr(event, "item", None)
if getattr(done_item, "type", None) == "azure_ai_search_call_output":
output = getattr(done_item, "output", None)
get_urls = getattr(output, "get_urls", None)
if get_urls is None and isinstance(output, dict):
get_urls = output.get("get_urls")
if get_urls is None and isinstance(output, str):
# Some SDK versions deliver `output` as a JSON string.
import json as _json
try:
parsed = _json.loads(output)
if isinstance(parsed, dict):
get_urls = parsed.get("get_urls")
except Exception: # noqa: BLE001
pass
if get_urls:
cache = getattr(self, _CACHE_ATTR, None)
if cache is None:
cache = []
setattr(self, _CACHE_ATTR, cache)
cache.extend(get_urls)
except Exception: # noqa: BLE001 - defensive: never break streaming
logger.debug(
"search-citation patch: failed to capture get_urls",
exc_info=True,
)

result = _original(self, event, *args, **kwargs)

# Enrich url_citation annotations emitted by the base method's
# response.output_text.annotation.added branch.
if event_type == "response.output_text.annotation.added":
try:
cache = getattr(self, _CACHE_ATTR, None) or []
if cache:
for content in (getattr(result, "contents", None) or []):
for ann in (getattr(content, "annotations", None) or []):
if not isinstance(ann, dict):
continue
if ann.get("type") != "citation":
continue
add_props = ann.get("additional_properties") or {}
# Idempotent: do not overwrite if upstream ever ships
# the fix and starts populating get_url itself.
if add_props.get("get_url"):
continue
# Map by title "doc_<N>" where N is the index into
# the search results (and thus into get_urls). The
# model can cite the same doc multiple times, so
# annotation_index (a running counter) is unreliable.
title = ann.get("title") or ""
m = _DOC_INDEX_RE.match(str(title))
doc_idx = int(m.group(1)) if m else None
if doc_idx is None:
doc_idx = add_props.get("annotation_index")
if isinstance(doc_idx, int) and 0 <= doc_idx < len(cache):
add_props["get_url"] = cache[doc_idx]
ann["additional_properties"] = add_props
except Exception: # noqa: BLE001
logger.debug(
"search-citation patch: failed to enrich annotation",
exc_info=True,
)

# Release per-stream state once the response completes.
if event_type == "response.completed":
try:
if hasattr(self, _CACHE_ATTR):
delattr(self, _CACHE_ATTR)
except Exception: # noqa: BLE001
pass

return result

setattr(target_cls, _TARGET_METHOD, _patched)
_PATCH_APPLIED = True
logger.info(
"Applied Azure AI Search citation patch on %s.%s (workaround for %s)",
_TARGET_CLASS, _TARGET_METHOD, _UPSTREAM_ISSUE,
)


# Apply on import so a single
# `import services._patches.agent_framework_search_citations`
# from chat_service.py is enough.
apply()
Loading
Loading