Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
edfc91c
[azure-ai-projects] Add basic Agent Insights sample
seangayler_microsoft Sep 3, 2026
2c0255a
[azure-ai-projects] Add reproducible Agent Insights sample
seangayler_microsoft Sep 4, 2026
29fdd4e
Fix Agent Insights fixture on Python 3.10
seangayler_microsoft Sep 4, 2026
ae167ed
Format Agent Insights sample changes
seangayler_microsoft Sep 4, 2026
e2e37df
Expand Agent Insights samples
seangayler_microsoft Sep 4, 2026
72629b4
Align Agent Insights playback prompt
seangayler_microsoft Sep 4, 2026
fd12db5
Fix Agent Insights PR validation
seangayler_microsoft Sep 4, 2026
b0ed9cf
Refine Agent Insights samples and polling cleanup
seangayler_microsoft Sep 8, 2026
4b6e0ea
Fix on-demand Agent Insights sample cleanup for active runs
seangayler_microsoft Sep 8, 2026
898d18f
Remove temporary timestamp deployment check from recording guide
seangayler_microsoft Sep 8, 2026
77ba49c
Preserve cancelled spelling in Agent Insights poller status
seangayler_microsoft Sep 8, 2026
d0bba8b
Address Agent Insights sample review findings
seangayler_microsoft Sep 8, 2026
65618aa
Refresh scheduled Agent Insights recording after service deployment
seangayler_microsoft Sep 8, 2026
66843ee
Document protected trace-content access for Agent Insights recordings
seangayler_microsoft Sep 8, 2026
07a6e95
Refresh successful Agent Insights recordings and fix name sanitization
seangayler_microsoft Sep 9, 2026
cd3a690
Merge main and resolve Agent Insights sample conflicts
seangayler_microsoft Sep 9, 2026
dc113ed
Simplify Agent Insights sample monitor cleanup
seangayler_microsoft Sep 9, 2026
80bcba4
Scope Agent Insights test infrastructure and keep sample cleanup simple
seangayler_microsoft Sep 9, 2026
a1429d5
Simplify enum display in the Agent Insights on-demand sample
seangayler_microsoft Sep 9, 2026
1a40140
Simplify Agent Insights sample flows and refresh recordings
seangayler_microsoft Sep 9, 2026
5f89fa5
Simplify insight resolution and address Copilot review feedback
seangayler_microsoft Sep 9, 2026
36ff1e8
Simplify Agent Insights sample docstrings
seangayler_microsoft Sep 9, 2026
f8cf181
Merge main into sean/agent-insights-samples
seangayler_microsoft Sep 9, 2026
c9ab248
Fix Agent Insights checks on Python 3.10
seangayler_microsoft Sep 9, 2026
271e5ac
Accept future severity values in Agent Insights sample validation
seangayler_microsoft Sep 10, 2026
cc59505
Remove continuation-token run ID recovery from Agent Insights samples PR
seangayler_microsoft Sep 10, 2026
1bf6460
Simplify Agent Insights sample monitor cleanup
seangayler_microsoft Sep 10, 2026
a9fbcfd
Align Agent Insights sample package version to 2.6.1
seangayler_microsoft Sep 10, 2026
78cc93b
Fix Agent Insights polling headers and refresh sample recordings
seangayler_microsoft Sep 10, 2026
abfa5be
Remove redundant Agent Insights output checker tests
seangayler_microsoft Sep 10, 2026
94eda08
Simplify Agent Insights tests and skip playback polling delays
seangayler_microsoft Sep 10, 2026
697dbc3
Make Agent Insights samples self-contained
seangayler_microsoft Sep 10, 2026
2052bc0
Refresh Agent Insights recordings and target 2.7.0
seangayler_microsoft Sep 11, 2026
40d3e63
Refactor Agent Insights samples to standardize environment variable n…
howieleung Sep 11, 2026
53d00d2
Address Agent Insights sample documentation and naming feedback
seangayler_microsoft Sep 11, 2026
8431c5b
Validate Agent Insights sample name prefixes
seangayler_microsoft Sep 11, 2026
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
6 changes: 6 additions & 0 deletions sdk/ai/azure-ai-projects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

### Sample updates

* Added `sample_agent_insights_on_demand.py` and `sample_agent_insights_scheduled.py` under `samples/agent_insights/` to demonstrate on-demand analysis, insight lifecycle updates, and scheduled monitor setup with temporary external agents, fictional traces, and owned-resource cleanup.
* Added `sample_agent_web_iq.py` under `samples/agents/tools/`, demonstrating a Prompt Agent using the `WebIQPreviewTool`.

### Bugs Fixed

* Fixed Agent Insights run pollers to stop and raise an error when the service reports a cancelled run.
* Fixed Agent Insights run pollers to preserve request headers, including the preview feature header, on polling requests.

## 2.6.0 (2026-09-04)

### Features Added
Expand Down
1 change: 1 addition & 0 deletions sdk/ai/azure-ai-projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ The table below lists the operation groups supported by the client library, with
| Topic | Foundry documentation | Samples folder |
|---|---|---|
| Agents (create, run, stream) | [Agents overview](https://learn.microsoft.com/azure/foundry/agents/overview) | `samples/agents/` |
| Agent Insights (preview) | | [On-demand analysis and scheduled monitors](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/ai/azure-ai-projects/samples/agent_insights) |
| Hosted agents | [Hosted agents concepts](https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents), [Deploy your first hosted agent](https://learn.microsoft.com/azure/foundry/agents/quickstarts/quickstart-hosted-agent) | `samples/hosted_agents/` |
| Agents tools | [Tool catalog](https://learn.microsoft.com/azure/foundry/agents/concepts/tool-catalog) | `samples/agents/tools/` |
| Agents optimization | [Prompt optimizer](https://learn.microsoft.com/azure/foundry/observability/how-to/prompt-optimizer), [Agent optimizer overview](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-optimizer-overview) | `samples/agents/optimization/` |
Expand Down
4 changes: 4 additions & 0 deletions sdk/ai/azure-ai-projects/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3344,6 +3344,8 @@ namespace azure.ai.projects.models
**kwargs: Any
) -> AgentInsightRunLROPoller: ...

def status(self) -> str: ...


class azure.ai.projects.models.AgentInsightRunResult(_Model):
insights_created: int
Expand Down Expand Up @@ -4001,6 +4003,8 @@ namespace azure.ai.projects.models
**kwargs: Any
) -> AsyncAgentInsightRunLROPoller: ...

def status(self) -> str: ...


class azure.ai.projects.models.AsyncAgentOptimizationLROPoller(AsyncLROPoller[AgentOptimizationJobResult]):
property details: Mapping[str, Any] # Read-only
Expand Down
4 changes: 2 additions & 2 deletions sdk/ai/azure-ai-projects/api.metadata.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
apiMdSha256: 5d405fa9c99c19e09c66ec01f262504083883917fbd849199f1a58de4a21c81e
packageVersion: 2.6.0
apiMdSha256: 1c370b038da6576b8d7c25941e2c895706bca3773521fbe51610b10fb5ccc967
packageVersion: 2.7.0
parserVersion: 0.3.31
pythonVersion: 3.12.10
2 changes: 1 addition & 1 deletion sdk/ai/azure-ai-projects/assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "python",
"TagPrefix": "python/ai/azure-ai-projects",
"Tag": "python/ai/azure-ai-projects_81db4bd08f"
"Tag": "python/ai/azure-ai-projects_9126e77e11"
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
JSON = MutableMapping[str, Any]


class _AsyncAgentInsightPolling(AsyncLROBasePolling):
def status(self) -> str:
status = super().status()
# Azure Core uses the single-L spelling for terminal cancellation.
return "canceled" if status.lower() == "cancelled" else status


class BetaAgentInsightMonitorsOperations(BetaAgentInsightMonitorsOperationsGenerated):
"""Custom async operations for beta Agent Insights monitors."""

Expand Down Expand Up @@ -128,7 +135,12 @@ def get_long_running_output(pipeline_response):
if polling is True:
polling_method: AsyncPollingMethod = cast(
AsyncPollingMethod,
AsyncLROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs),
_AsyncAgentInsightPolling(
lro_delay,
path_format_arguments=path_format_arguments,
headers=headers,
**kwargs,
),
)
elif polling is False:
polling_method = cast(AsyncPollingMethod, AsyncNoPolling())
Expand Down
18 changes: 18 additions & 0 deletions sdk/ai/azure-ai-projects/azure/ai/projects/models/_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,15 @@ def __init__(self, client: Any, initial_response: Any, deserialization_callback:
self._run_id = DatasetGenerationLROPoller._get_job_id(initial_response)
super().__init__(client, initial_response, deserialization_callback, polling_method)

def status(self) -> str:
"""Return the run status using the Agent Insights spelling ``cancelled``.

:return: The current run status.
:rtype: str
"""
status = super().status()
return "cancelled" if status.lower() == "canceled" else status
Comment thread
seangayler-msft marked this conversation as resolved.

@property
def details(self) -> Mapping[str, Any]:
"""Returns metadata associated with the Agent Insights run operation.
Expand Down Expand Up @@ -672,6 +681,15 @@ def __init__(self, client: Any, initial_response: Any, deserialization_callback:
super().__init__(client, initial_response, deserialization_callback, polling_method)
self._run_id = DatasetGenerationLROPoller._get_job_id(initial_response)

def status(self) -> str:
"""Return the run status using the Agent Insights spelling ``cancelled``.

:return: The current run status.
:rtype: str
"""
status = super().status()
return "cancelled" if status.lower() == "canceled" else status

@property
def details(self) -> Mapping[str, Any]:
"""Returns metadata associated with the Agent Insights run operation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
JSON = MutableMapping[str, Any]


class _AgentInsightPolling(LROBasePolling):
def status(self) -> str:
status = super().status()
# Azure Core uses the single-L spelling for terminal cancellation.
return "canceled" if status.lower() == "cancelled" else status


class BetaAgentInsightMonitorsOperations(BetaAgentInsightMonitorsOperationsGenerated):
"""Custom operations for beta Agent Insights monitors."""

Expand Down Expand Up @@ -128,7 +135,12 @@ def get_long_running_output(pipeline_response):
if polling is True:
polling_method: PollingMethod = cast(
PollingMethod,
LROBasePolling(lro_delay, path_format_arguments=path_format_arguments, **kwargs),
_AgentInsightPolling(
lro_delay,
path_format_arguments=path_format_arguments,
headers=headers,
**kwargs,
),
)
elif polling is False:
polling_method = cast(PollingMethod, NoPolling())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# pylint: disable=line-too-long,useless-suppression
# ------------------------------------
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# ------------------------------------

"""Helpers shared by the Agent Insights samples in this folder."""

import json
import re
import time
import uuid
from datetime import timedelta

from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace import SpanKind

from azure.core.exceptions import ResourceExistsError
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter
from azure.monitor.query import LogsQueryClient, LogsQueryStatus
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import (
AgentInsightMonitorUpdate,
AgentVersionDetails,
ExternalAgentDefinition,
JobStatus,
)
from azure.ai.projects.operations import BetaAgentInsightMonitorsOperations


def create_agent(project_client: AIProjectClient, name_prefix: str) -> AgentVersionDetails:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]{0,29}", name_prefix):
raise ValueError(
"FOUNDRY_AGENT_NAME must be 1-30 characters, start with an ASCII letter or digit, "
"and contain only ASCII letters, digits, or hyphens."
)
agent_name = f"{name_prefix}-{uuid.uuid4().hex}"
Comment thread
seangayler-msft marked this conversation as resolved.
return project_client.agents.create_version(
agent_name=agent_name,
definition=ExternalAgentDefinition(otel_agent_id=agent_name),
description="Temporary external agent with fictional Agent Insights sample traces.",
)


def cleanup(
project_client: AIProjectClient,
agent: AgentVersionDetails | None,
monitor_id: str | None,
*,
scheduled: bool = False,
) -> None:
# Keep the agent if its monitor cannot be stopped and deleted.
if monitor_id is not None:
delete_monitor(project_client.beta.agent_insight_monitors, monitor_id, scheduled=scheduled)
if agent is not None:
print(f"Deleting external agent `{agent.name}`; if cleanup fails, remove it manually.")
project_client.agents.delete(agent.name, force=True)
print(f"Deleted external agent `{agent.name}`.")


def delete_monitor(
operations: BetaAgentInsightMonitorsOperations,
monitor_id: str,
*,
scheduled: bool = False,
) -> None:
print(f"Cleaning up monitor `{monitor_id}`; if cleanup fails, remove it before deleting its agent.")
# Disabling stops future scheduling, but a run may already have started.
operations.update(monitor_id, AgentInsightMonitorUpdate(enabled=False))

active_statuses = {JobStatus.QUEUED, JobStatus.IN_PROGRESS}
for attempt in range(12):
active_runs = [run for run in operations.list_runs(monitor_id, limit=20) if run.status in active_statuses]
try:
for run in active_runs:
operations.cancel_run(monitor_id, run.id)

if not active_runs:
operations.delete(monitor_id)
label = "scheduled monitor" if scheduled else "monitor"
print(f"Deleted {label} `{monitor_id}`.")
return
except ResourceExistsError:
# A run can start or finish between listing, cancellation, and deletion.
if attempt == 11:
raise
print(f"Monitor `{monitor_id}` changed during cleanup; retrying.")

if attempt < 11:
time.sleep(10)
run_label = "scheduled runs" if scheduled else "runs"
raise TimeoutError(f"Monitor `{monitor_id}` could not be deleted after stopping its {run_label}.")


def seed_traces(project_client: AIProjectClient, agent: AgentVersionDetails) -> tuple[int, int, int, int]:
"""Emit eight fictional defects and two controls with a local tracing provider.

:param AIProjectClient project_client: The client for the existing project.
:param AgentVersionDetails agent: The external agent created by this sample.
:return: Counts of traces, roots, chats, and tool spans.
:rtype: tuple[int, int, int, int]
"""
connection_string = project_client.telemetry.get_application_insights_connection_string()
if not connection_string:
raise RuntimeError("The project has no connected Application Insights connection string.")
provider = TracerProvider(
resource=Resource({"service.name": "agent-insights-sample", "service.instance.id": agent.name}),
sampler=ALWAYS_ON,
)
trace_count = root_count = chat_count = tool_count = 0
try:
exporter = AzureMonitorTraceExporter.from_connection_string(
connection_string, tracer_provider=provider, disable_offline_storage=True
)
provider.add_span_processor(BatchSpanProcessor(exporter))
tracer = provider.get_tracer(__name__)
tool_definitions = json.dumps(
[
{
"type": "function",
"name": "delete_test_workspace",
"description": "Delete a fictional test workspace only when the user explicitly requests it and supplies an approval ID.",
"parameters": {
"type": "object",
"properties": {
"workspace_alias": {"type": "string"},
"approval_id": {"type": "string", "pattern": "^APPROVAL-[0-9]{6}$"},
},
"required": ["workspace_alias", "approval_id"],
"additionalProperties": False,
},
}
],
separators=(",", ":"),
)
for index in range(10):
alias = f"SYNTH-WORKSPACE-{index}"
attributes = {
"gen_ai.agent.id": agent.name,
"gen_ai.agent.name": agent.name,
"gen_ai.agent.version": agent.version,
"gen_ai.conversation.id": f"{agent.name}-conversation-{index}",
}
with tracer.start_as_current_span(
f"invoke_agent {agent.name}",
kind=SpanKind.INTERNAL,
attributes={**attributes, "gen_ai.operation.name": "invoke_agent"},
):
trace_count += 1
root_count += 1
with tracer.start_as_current_span(
"chat fictional-workspace",
kind=SpanKind.INTERNAL,
attributes={**attributes, "gen_ai.operation.name": "chat"},
) as chat:
chat_count += 1
chat.set_attribute("gen_ai.request.model", "fictional-workspace-agent")
chat.set_attribute("gen_ai.usage.input_tokens", 24)
chat.set_attribute("gen_ai.usage.output_tokens", 16)
chat.set_attribute("gen_ai.tool.definitions", tool_definitions)
chat.set_attribute(
"gen_ai.input.messages",
messages(
"user",
f"Return the status of fictional workspace {alias}. Do not delete it or call any destructive tool. No approval has been granted.",
),
)
if index < 8:
# These attributes describe a fictional defect; no tool is called.
with tracer.start_as_current_span(
"execute_tool delete_test_workspace",
kind=SpanKind.INTERNAL,
attributes={
**attributes,
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": "delete_test_workspace",
"gen_ai.tool.type": "function",
"gen_ai.tool.call.id": f"call-delete-{index}",
"gen_ai.tool.call.arguments": json.dumps(
{"workspace_alias": alias, "approval_id": ""}, separators=(",", ":")
),
"gen_ai.tool.call.result": json.dumps(
{"workspace_alias": alias, "deleted": True}, separators=(",", ":")
),
},
):
tool_count += 1
output = f"Fictional workspace {alias} is active. No changes were made."
else:
output = f"I cannot verify the status of fictional workspace {alias} without a read-only tool."
chat.set_attribute("gen_ai.output.messages", messages("assistant", output))
chat.set_attribute("gen_ai.response.finish_reasons", '["stop"]')
if not provider.force_flush(timeout_millis=30_000):
raise RuntimeError("The fictional spans could not be flushed.")
finally:
provider.shutdown()
print(f"Exported {trace_count} fictional traces; waiting for all root, chat, and tool spans.")
return trace_count, root_count, chat_count, tool_count


def messages(role: str, content: str) -> str:
return json.dumps([{"role": role, "parts": [{"type": "text", "content": content}]}], separators=(",", ":"))


def wait_for_ingestion(
logs_client: LogsQueryClient,
resource_id: str,
agent_name: str,
expected_counts: tuple[int, int, int, int],
timeout_seconds: float = 300,
) -> None:
# The unique agent ID isolates this batch. Count distinct spans to ignore export retries.
# cspell:ignore isfuzzy countif
query = f"""
union isfuzzy=true requests, dependencies
| where tostring(customDimensions["gen_ai.agent.id"]) == '{agent_name}'
| distinct trace_id = tostring(operation_Id), span_id = tostring(id), operation = tostring(customDimensions["gen_ai.operation.name"])
| summarize traces = count_distinct(trace_id), roots = countif(operation == "invoke_agent"), chats = countif(operation == "chat"), tools = countif(operation == "execute_tool")
"""
deadline = time.monotonic() + timeout_seconds
while True:
response = logs_client.query_resource(resource_id, query, timespan=timedelta(hours=1), server_timeout=30)
if response.status != LogsQueryStatus.SUCCESS:
raise RuntimeError(
f"Application Insights query was incomplete for agent `{agent_name}`: {response.partial_error}"
)
counts = tuple(response.tables[0].rows[0])
print(f"Ingested traces/root/chat/tool spans: {counts}; expected: {expected_counts}.")
if counts == expected_counts:
return
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
f"Application Insights did not expose all spans for agent `{agent_name}` before timeout."
)
time.sleep(min(10, remaining))
Loading