Compare commits

...
Author SHA1 Message Date
Victoria Hall cb1ee732ed semi working 2025-11-14 16:27:37 -06:00
Evan MattsonandGitHub a75590eb9b Python: ChatKit sample fixes (#2174)
* sample fixes

* Update thread naming
2025-11-13 23:02:31 +00:00
15d0bda8a2 Python: Added an Azure OpenAI Responses API Hosted MCP sample (#2108)
* Add files via upload

* Update python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Updated README.md

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 22:44:49 +00:00
Giles OdigweandGitHub f04f5ef297 Python: Added Samples for Bing Grounding and Custom Search (#2200)
* bing grounding and custom search samples

* readme
2025-11-13 21:57:16 +00:00
Giles OdigweandGitHub 21dceca482 Python: Enhance Azure AI Search Citations with Complete URL Information (#2066)
* add get_url to raw rep for absolute path url

* fixes

* add real url to citation annotation

* small fix

* project client + openapi fix

* openapi sample revert

* tool call list fix
2025-11-13 19:23:11 +00:00
SergeyMenshykhandGitHub 6d890e46ed suppress the MEAI001 and OPENAI001 errors that appear when building the catalog sample as a standalone project. (#2191) 2025-11-13 18:48:50 +00:00
Shyju KrishnankuttyandGitHub de2abdf573 Updated the Azure Functions samples to use the latest stable Azure Functions Worker packages. (#2189) 2025-11-13 17:38:42 +00:00
westeyandGitHub f273ca7353 Fix InMemoryChatMessageStore serialization bug. (#2185) 2025-11-13 15:43:01 +00:00
Victor DibiaandGitHub 7e5de8f920 Python: Fix HIL regression (#2167)
* fix devui regression from #2021 where all input is stringified but devui HIL input does not handle stringified json strings correctly.

* update incorrect test

* add devui hil input tests
2025-11-13 05:26:24 +00:00
Evan MattsonandGitHub e92dcb3d5d Python: fix tool call id mismatch in ag-ui (#2166)
* Fix state for pending requests bug

* Bump ver

* Update changelog
2025-11-13 04:57:59 +00:00
32 changed files with 5331 additions and 3818 deletions
+3 -3
View File
@@ -103,14 +103,14 @@
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.2.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<!-- Community -->
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<!-- Test -->
@@ -15,6 +15,7 @@
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<!--
@@ -97,8 +97,9 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
if (serializedStoreState.ValueKind is JsonValueKind.Object)
{
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
var state = serializedStoreState.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState;
jso.GetTypeInfo(typeof(StoreState))) as StoreState;
if (state?.Messages is { } messages)
{
this._messages = messages;
@@ -164,7 +165,8 @@ public sealed class InMemoryChatMessageStore : ChatMessageStore, IList<ChatMessa
Messages = this._messages,
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState)));
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(StoreState)));
}
/// <inheritdoc />
@@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -114,6 +116,29 @@ public class InMemoryChatMessageStoreTests
Assert.Equal("B", newStore[1].Text);
}
[Fact]
public async Task SerializeAndDeserializeConstructorRoundtripsWithCustomAIContentAsync()
{
JsonSerializerOptions options = new(TestJsonSerializerContext.Default.Options)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver, TestJsonSerializerContext.Default),
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
options.AddAIContentType<TestAIContent>(typeDiscriminatorId: "testContent");
var store = new InMemoryChatMessageStore
{
new ChatMessage(ChatRole.User, [new TestAIContent("foo data")]),
};
var jsonElement = store.Serialize(options);
var newStore = new InMemoryChatMessageStore(jsonElement, options);
Assert.Single(newStore);
var actualTestAIContent = Assert.IsType<TestAIContent>(newStore[0].Contents[0]);
Assert.Equal("foo data", actualTestAIContent.TestData);
}
[Fact]
public async Task SerializeAndDeserializeWorksWithExperimentalContentTypesAsync()
{
@@ -558,4 +583,9 @@ public class InMemoryChatMessageStoreTests
Assert.Equal("Hello", result[0].Text);
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Never);
}
public class TestAIContent(string testData) : AIContent
{
public string TestData => testData;
}
}
@@ -22,4 +22,5 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
[JsonSerializable(typeof(InMemoryAgentThread.InMemoryAgentThreadState))]
[JsonSerializable(typeof(ServiceIdAgentThread.ServiceIdAgentThreadState))]
[JsonSerializable(typeof(ServiceIdAgentThreadTests.EmptyObject))]
[JsonSerializable(typeof(InMemoryChatMessageStoreTests.TestAIContent))]
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;
+4
View File
@@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **agent-framework-azurefunctions**: Merge Azure Functions feature branch (#1916)
### Fixed
- **agent-framework-ag-ui**: fix tool call id mismatch in ag-ui ([#2166](https://github.com/microsoft/agent-framework/pull/2166))
## [1.0.0b251112] - 2025-11-12
### Added
@@ -316,43 +316,66 @@ class DefaultOrchestrator(Orchestrator):
)
continue
if role_value == "user" and pending_confirm_changes_id:
# Check if this is a confirm_changes response (JSON with "accepted" field)
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
break
if role_value == "user":
# Check if this user message is a confirm_changes response (JSON with "accepted" field)
# This must be checked BEFORE injecting synthetic results for pending tool calls
if pending_confirm_changes_id:
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
break
try:
parsed = json.loads(user_text)
if "accepted" in parsed:
# This is a confirm_changes response - inject synthetic tool result
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
)
try:
parsed = json.loads(user_text)
if "accepted" in parsed:
# This is a confirm_changes response - inject synthetic tool result
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
)
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
)
],
)
sanitized.append(synthetic_result)
if pending_tool_call_ids:
pending_tool_call_ids.discard(pending_confirm_changes_id)
pending_confirm_changes_id = None
# Don't add the user message to sanitized - it's been converted to tool result
continue
except (json.JSONDecodeError, KeyError) as e:
# Failed to parse user message as confirm_changes response; continue normal processing
logger.debug(f"Could not parse user message as confirm_changes response: {e}")
# Before processing user message, check if there are pending tool calls without results
# This happens when assistant made multiple tool calls but only some got results
# This is checked AFTER confirm_changes special handling above
if pending_tool_call_ids:
logger.info(
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results"
)
for pending_call_id in pending_tool_call_ids:
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
call_id=pending_call_id,
result="Tool execution skipped - user provided follow-up message",
)
],
)
sanitized.append(synthetic_result)
if pending_tool_call_ids:
pending_tool_call_ids.discard(pending_confirm_changes_id)
pending_confirm_changes_id = None
# Don't add the user message to sanitized - it's been converted to tool result
continue
except (json.JSONDecodeError, KeyError) as e:
# Failed to parse user message as confirm_changes response; continue normal processing
logger.debug(f"Could not parse user message as confirm_changes response: {e}")
pending_tool_call_ids = None
pending_confirm_changes_id = None
# Not a confirm_changes response, continue normal processing
# Normal user message processing
sanitized.append(msg)
pending_tool_call_ids = None
pending_confirm_changes_id = None
continue
@@ -365,6 +388,14 @@ class DefaultOrchestrator(Orchestrator):
call_id = str(content.call_id)
if call_id in pending_tool_call_ids:
keep = True
# Note: We do NOT remove call_id from pending here.
# This allows duplicate tool results to pass through sanitization
# so the deduplicator can choose the best one (prefer non-empty results).
# We only clear pending_tool_call_ids when a user message arrives.
if call_id == pending_confirm_changes_id:
# For confirm_changes specifically, we do want to clear it
# since we only expect one response
pending_confirm_changes_id = None
break
if keep:
sanitized.append(msg)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251112"
version = "1.0.0b251112.post1"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -1,7 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
import ast
import json
import os
import re
import sys
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, TypeVar
@@ -427,7 +429,9 @@ class AzureAIAgentClient(BaseChatClient):
# and remove until here.
return thread_id
def _extract_url_citations(self, message_delta_chunk: MessageDeltaChunk) -> list[CitationAnnotation]:
def _extract_url_citations(
self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]]
) -> list[CitationAnnotation]:
"""Extract URL citations from MessageDeltaChunk."""
url_citations: list[CitationAnnotation] = []
@@ -446,10 +450,15 @@ class AzureAIAgentClient(BaseChatClient):
)
]
# Create CitationAnnotation from AzureAI annotation
# Extract real URL from Azure AI Search tool calls
real_url = self._get_real_url_from_citation_reference(
annotation.url_citation.url, azure_search_tool_calls
)
# Create CitationAnnotation with real URL
citation = CitationAnnotation(
title=getattr(annotation.url_citation, "title", None),
url=annotation.url_citation.url,
url=real_url,
snippet=None,
annotated_regions=annotated_regions,
raw_representation=annotation,
@@ -458,11 +467,54 @@ class AzureAIAgentClient(BaseChatClient):
return url_citations
def _get_real_url_from_citation_reference(
self, citation_url: str, azure_search_tool_calls: list[dict[str, Any]]
) -> str:
"""Extract real URL from Azure AI Search tool calls based on citation reference.
Args:
citation_url: Citation reference URL (e.g., "doc_0", "#doc_1", or full URL with doc_N)
azure_search_tool_calls: List of captured Azure AI Search tool calls
Returns:
Real document URL if found, otherwise original citation_url
"""
# Extract document index from citation URL (e.g., "doc_0" -> 0)
match = re.search(r"doc_(\d+)", citation_url)
if not match:
return citation_url
doc_index = int(match.group(1))
# Get Azure AI Search tool calls
if not azure_search_tool_calls:
return citation_url
try:
# Extract URLs from the most recent Azure AI Search tool call
tool_call = azure_search_tool_calls[-1] # Most recent call
output_str = tool_call["azure_ai_search"]["output"]
# Parse the tool call output to get URLs
output_data = ast.literal_eval(output_str)
all_urls = output_data["metadata"]["get_urls"]
# Return the URL at the specified index, if it exists
if 0 <= doc_index < len(all_urls):
return str(all_urls[doc_index])
except (KeyError, IndexError, TypeError, ValueError, SyntaxError) as ex:
logger.debug(f"Failed to extract real URL for {citation_url}: {ex}")
return citation_url
async def _process_stream(
self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str
) -> AsyncIterable[ChatResponseUpdate]:
"""Process events from the stream iterator and yield ChatResponseUpdate objects."""
response_id: str | None = None
# Track Azure Search tool calls for this stream only
azure_search_tool_calls: list[dict[str, Any]] = []
response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call]
try:
async for event_type, event_data, _ in response_stream: # type: ignore
@@ -472,7 +524,7 @@ class AzureAIAgentClient(BaseChatClient):
role = Role.USER if event_data.delta.role == MessageRole.USER else Role.ASSISTANT
# Extract URL citations from the delta chunk
url_citations = self._extract_url_citations(event_data)
url_citations = self._extract_url_citations(event_data, azure_search_tool_calls)
# Create contents with citations if any exist
citation_content: list[Contents] = []
@@ -545,6 +597,10 @@ class AzureAIAgentClient(BaseChatClient):
case AgentStreamEvent.THREAD_RUN_STEP_CREATED:
response_id = event_data.run_id
case AgentStreamEvent.THREAD_RUN_COMPLETED | AgentStreamEvent.THREAD_RUN_STEP_COMPLETED:
# Capture Azure AI Search tool calls when steps complete
if event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED:
self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls)
if event_data.usage:
usage_content = UsageContent(
UsageDetails(
@@ -623,6 +679,29 @@ class AzureAIAgentClient(BaseChatClient):
if isinstance(stream, AsyncAgentRunStream):
await stream.__aexit__(None, None, None) # type: ignore[no-untyped-call]
def _capture_azure_search_tool_calls(
self, step_data: RunStep, azure_search_tool_calls: list[dict[str, Any]]
) -> None:
"""Capture Azure AI Search tool call data from completed steps."""
try:
if (
hasattr(step_data, "step_details")
and hasattr(step_data.step_details, "tool_calls")
and step_data.step_details.tool_calls
):
for tool_call in step_data.step_details.tool_calls:
if hasattr(tool_call, "type") and tool_call.type == "azure_ai_search":
# Store the complete tool call as a dictionary
tool_call_dict = {
"id": getattr(tool_call, "id", None),
"type": tool_call.type,
"azure_ai_search": getattr(tool_call, "azure_ai_search", None),
}
azure_search_tool_calls.append(tool_call_dict)
logger.debug(f"Captured Azure AI Search tool call: {tool_call_dict['id']}")
except Exception as ex:
logger.debug(f"Failed to capture Azure AI Search tool call: {ex}")
def _create_function_call_contents(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
"""Create function call contents from a tool action event."""
if isinstance(event_data, ThreadRun) and event_data.required_action is not None:
@@ -3,7 +3,7 @@
import json
import os
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -92,6 +92,7 @@ def create_test_azure_ai_chat_client(
client._agent_created = False
client._should_close_client = False
client._agent_definition = None
client._azure_search_tool_calls = [] # Add the new instance variable
client.additional_properties = {}
client.middleware = None
@@ -1335,8 +1336,8 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c
mock_chunk = MagicMock(spec=MessageDeltaChunk)
mock_chunk.delta = mock_delta
# Call the method
citations = chat_client._extract_url_citations(mock_chunk) # type: ignore
# Call the method with empty azure_search_tool_calls
citations = chat_client._extract_url_citations(mock_chunk, []) # type: ignore
# Verify results
assert len(citations) == 1
@@ -1804,3 +1805,166 @@ async def test_azure_ai_chat_client_no_cleanup_when_agent_not_created_by_client(
# Verify agent was NOT deleted
mock_agents_client.delete_agent.assert_not_called()
assert chat_client.agent_id == "existing-agent-id"
def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client: MagicMock) -> None:
"""Test _capture_azure_search_tool_calls method."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Mock Azure AI Search tool call
mock_tool_call = MagicMock()
mock_tool_call.type = "azure_ai_search"
mock_tool_call.id = "call_123"
mock_tool_call.azure_ai_search = {"input": "test query", "output": "test output"}
# Mock step data
mock_step_data = MagicMock()
mock_step_data.step_details.tool_calls = [mock_tool_call]
# Call the method with a list to capture tool calls
azure_search_tool_calls: list[dict[str, Any]] = []
chat_client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore
# Verify tool call was captured
assert len(azure_search_tool_calls) == 1
captured_tool_call = azure_search_tool_calls[0]
assert captured_tool_call["type"] == "azure_ai_search"
assert captured_tool_call["id"] == "call_123"
assert captured_tool_call["azure_ai_search"] == {"input": "test query", "output": "test output"}
def test_azure_ai_chat_client_get_real_url_from_citation_reference_no_tool_calls(
mock_agents_client: MagicMock,
) -> None:
"""Test _get_real_url_from_citation_reference with no tool calls."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# No tool calls - pass empty list
result = chat_client._get_real_url_from_citation_reference("doc_1", []) # type: ignore
assert result == "doc_1"
def test_azure_ai_chat_client_get_real_url_from_citation_reference_invalid_output(
mock_agents_client: MagicMock,
) -> None:
"""Test _get_real_url_from_citation_reference with invalid output format."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Tool call with invalid output format
azure_search_tool_calls = [
{"id": "call_123", "type": "azure_ai_search", "azure_ai_search": {"output": "invalid_json_format"}}
]
result = chat_client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore
assert result == "doc_1"
async def test_azure_ai_chat_client_context_manager(mock_agents_client: MagicMock) -> None:
"""Test AzureAIAgentClient as async context manager."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Mock close method to avoid actual cleanup
chat_client.close = AsyncMock()
async with chat_client as client:
assert client is chat_client
# Verify close was called on exit
chat_client.close.assert_called_once()
async def test_azure_ai_chat_client_close_method(mock_agents_client: MagicMock) -> None:
"""Test AzureAIAgentClient close method."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Mock cleanup methods
chat_client._cleanup_agent_if_needed = AsyncMock()
chat_client._close_client_if_needed = AsyncMock()
await chat_client.close()
# Verify cleanup methods were called
chat_client._cleanup_agent_if_needed.assert_called_once()
chat_client._close_client_if_needed.assert_called_once()
def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_url(
mock_agents_client: MagicMock,
) -> None:
"""Test _extract_url_citations with Azure AI Search URL enhancement."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Add Azure Search tool calls for URL enhancement
azure_search_tool_calls = [
{
"id": "call_123",
"type": "azure_ai_search",
"azure_ai_search": {
"output": str({
"metadata": {"get_urls": ["https://real-example.com/doc1", "https://real-example.com/doc2"]}
})
},
}
]
# Create mock URL citation with doc reference
mock_url_citation = MagicMock()
mock_url_citation.url = "doc_1"
mock_url_citation.title = "Test Title"
mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation)
mock_annotation.url_citation = mock_url_citation
mock_annotation.start_index = 10
mock_annotation.end_index = 20
mock_text = MagicMock()
mock_text.annotations = [mock_annotation]
mock_text_content = MagicMock(spec=MessageDeltaTextContent)
mock_text_content.text = mock_text
mock_delta = MagicMock()
mock_delta.content = [mock_text_content]
mock_chunk = MagicMock(spec=MessageDeltaChunk)
mock_chunk.delta = mock_delta
citations = chat_client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore
# Verify real URL was used
assert len(citations) == 1
citation = citations[0]
assert citation.url == "https://real-example.com/doc2" # doc_1 maps to index 1
def test_azure_ai_chat_client_init_with_auto_created_agents_client(
azure_ai_unit_test_env: dict[str, str], mock_azure_credential: MagicMock
) -> None:
"""Test AzureAIAgentClient initialization when it creates its own AgentsClient."""
# Mock the AgentsClient constructor
with patch("agent_framework_azure_ai._chat_client.AgentsClient") as mock_agents_client_class:
mock_agents_client_instance = MagicMock()
mock_agents_client_class.return_value = mock_agents_client_instance
# Create client without providing agents_client - should create its own
client = AzureAIAgentClient(
agents_client=None, # This will trigger creation of AgentsClient
agent_id="test-agent",
project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
async_credential=mock_azure_credential,
)
# Verify AgentsClient was created with correct parameters
mock_agents_client_class.assert_called_once_with(
endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
credential=mock_azure_credential,
user_agent="agent-framework-python/0.0.0",
)
# Verify client properties are set correctly
assert client.agents_client is mock_agents_client_instance
assert client.agent_id == "test-agent"
assert client.credential is mock_azure_credential
assert client._should_close_client is True # Should close since we created it # type: ignore[attr-defined]
@@ -16,11 +16,11 @@ import azure.functions as func
from agent_framework import AgentProtocol, get_logger
from ._callbacks import AgentResponseCallbackProtocol
from ._durable_agent_state import DurableAgentState
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._models import AgentSessionId, RunRequest
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions")
@@ -34,9 +34,6 @@ WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
EntityHandler = Callable[[df.DurableEntityContext], None]
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
if TYPE_CHECKING:
class DFAppBase:
@@ -73,16 +70,17 @@ class AgentFunctionApp(DFAppBase):
.. code-block:: python
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp
from agent_framework.azure import AzureOpenAIAssistantsClient
# Create agents with unique names
weather_agent = AzureOpenAIChatClient(...).create_agent(
weather_agent = AzureOpenAIAssistantsClient(...).create_agent(
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=[get_weather],
)
math_agent = AzureOpenAIChatClient(...).create_agent(
math_agent = AzureOpenAIAssistantsClient(...).create_agent(
name="MathAgent",
instructions="You are a helpful math assistant.",
tools=[calculate],
@@ -130,23 +128,23 @@ class AgentFunctionApp(DFAppBase):
http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION,
enable_health_check: bool = True,
enable_http_endpoints: bool = True,
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
max_poll_retries: int = 30,
poll_interval_seconds: float = 1,
default_callback: AgentResponseCallbackProtocol | None = None,
):
"""Initialize the AgentFunctionApp.
:param agents: List of agent instances to register.
:param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``).
:param enable_health_check: Enable the built-in health check endpoint (default: ``True``).
:param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``).
:param max_poll_retries: Maximum polling attempts when waiting for a response.
Defaults to ``DEFAULT_MAX_POLL_RETRIES``.
:param poll_interval_seconds: Delay in seconds between polling attempts.
Defaults to ``DEFAULT_POLL_INTERVAL_SECONDS``.
:param default_callback: Optional callback invoked for agents without specific callbacks.
Args:
agents: List of agent instances to register
http_auth_level: HTTP authentication level (default: FUNCTION)
enable_health_check: Enable built-in health check endpoint (default: True)
enable_http_endpoints: Enable HTTP endpoints for agents (default: True)
max_poll_retries: Maximum number of polling attempts when waiting for a response
poll_interval_seconds: Delay (in seconds) between polling attempts
default_callback: Optional callback invoked for agents without specific callbacks
:note: If no agents are provided, they can be added later using :meth:`add_agent`.
Note:
If no agents are provided, they can be added later using add_agent().
"""
logger.debug("[AgentFunctionApp] Initializing with Durable Entities...")
@@ -163,14 +161,14 @@ class AgentFunctionApp(DFAppBase):
try:
retries = int(max_poll_retries)
except (TypeError, ValueError):
retries = DEFAULT_MAX_POLL_RETRIES
retries = 10
self.max_poll_retries = max(1, retries)
try:
interval = float(poll_interval_seconds)
except (TypeError, ValueError):
interval = DEFAULT_POLL_INTERVAL_SECONDS
self.poll_interval_seconds = interval if interval > 0 else DEFAULT_POLL_INTERVAL_SECONDS
interval = 0.5
self.poll_interval_seconds = interval if interval > 0 else 0.5
if agents:
# Register all provided agents
@@ -339,10 +337,10 @@ class AgentFunctionApp(DFAppBase):
)
session_id = self._create_session_id(agent_name, thread_id)
correlation_id = self._generate_unique_id()
correlationId = self._generate_unique_id()
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlationId}")
logger.debug("[HTTP Trigger] Calling entity to run agent...")
entity_instance_id = session_id.to_entity_id()
@@ -350,7 +348,7 @@ class AgentFunctionApp(DFAppBase):
req_body,
message,
thread_id,
correlation_id,
correlationId,
)
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
await client.signal_entity(entity_instance_id, "run_agent", run_request)
@@ -361,7 +359,7 @@ class AgentFunctionApp(DFAppBase):
result = await self._get_response_from_entity(
client=client,
entity_instance_id=entity_instance_id,
correlation_id=correlation_id,
correlationId=correlationId,
message=message,
thread_id=thread_id,
)
@@ -377,7 +375,7 @@ class AgentFunctionApp(DFAppBase):
logger.debug("[HTTP Trigger] wait_for_response disabled; returning correlation ID")
accepted_response = self._build_accepted_response(
message=message, thread_id=thread_id, correlation_id=correlation_id
message=message, thread_id=thread_id, correlationId=correlationId
)
return self._create_http_response(
@@ -491,7 +489,7 @@ class AgentFunctionApp(DFAppBase):
self,
client: df.DurableOrchestrationClient,
entity_instance_id: df.EntityId,
) -> AgentState | None:
) -> DurableAgentState | None:
state_response = await client.read_entity_state(entity_instance_id)
if not state_response or not state_response.entity_exists:
return None
@@ -502,7 +500,7 @@ class AgentFunctionApp(DFAppBase):
typed_state_payload = cast(dict[str, Any], state_payload)
agent_state = AgentState()
agent_state = DurableAgentState()
agent_state.restore_state(typed_state_payload)
return agent_state
@@ -510,7 +508,7 @@ class AgentFunctionApp(DFAppBase):
self,
client: df.DurableOrchestrationClient,
entity_instance_id: df.EntityId,
correlation_id: str,
correlationId: str,
message: str,
thread_id: str,
) -> dict[str, Any]:
@@ -522,7 +520,7 @@ class AgentFunctionApp(DFAppBase):
retry_count = 0
result: dict[str, Any] | None = None
logger.debug(f"[HTTP Trigger] Waiting for response with correlation ID: {correlation_id}")
logger.debug(f"[HTTP Trigger] Waiting for response with correlation ID: {correlationId}")
while retry_count < max_retries:
await asyncio.sleep(interval)
@@ -530,7 +528,7 @@ class AgentFunctionApp(DFAppBase):
result = await self._poll_entity_for_response(
client=client,
entity_instance_id=entity_instance_id,
correlation_id=correlation_id,
correlationId=correlationId,
message=message,
thread_id=thread_id,
)
@@ -544,16 +542,16 @@ class AgentFunctionApp(DFAppBase):
return result
logger.warning(
f"[HTTP Trigger] Response with correlation ID {correlation_id} "
f"[HTTP Trigger] Response with correlation ID {correlationId} "
f"not found in time (waited {max_retries * interval} seconds)"
)
return await self._build_timeout_result(message=message, thread_id=thread_id, correlation_id=correlation_id)
return await self._build_timeout_result(message=message, thread_id=thread_id, correlationId=correlationId)
async def _poll_entity_for_response(
self,
client: df.DurableOrchestrationClient,
entity_instance_id: df.EntityId,
correlation_id: str,
correlationId: str,
message: str,
thread_id: str,
) -> dict[str, Any] | None:
@@ -564,34 +562,34 @@ class AgentFunctionApp(DFAppBase):
if state is None:
return None
agent_response = state.try_get_agent_response(correlation_id)
agent_response = state.try_get_agent_response(correlationId)
if agent_response:
result = self._build_success_result(
response_data=agent_response,
message=message,
thread_id=thread_id,
correlation_id=correlation_id,
correlationId=correlationId,
state=state,
)
logger.debug(f"[HTTP Trigger] Found response for correlation ID: {correlation_id}")
logger.debug(f"[HTTP Trigger] Found response for correlation ID: {correlationId}")
except Exception as exc:
logger.warning(f"[HTTP Trigger] Error reading entity state: {exc}")
return result
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
async def _build_timeout_result(self, message: str, thread_id: str, correlationId: str) -> dict[str, Any]:
"""Create the timeout response."""
return {
"response": "Agent is still processing or timed out...",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "timeout",
"correlation_id": correlation_id,
"correlationId": correlationId,
}
def _build_success_result(
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
self, response_data: dict[str, Any], message: str, thread_id: str, correlationId: str, state: DurableAgentState
) -> dict[str, Any]:
"""Build the success result returned to the HTTP caller."""
return {
@@ -600,11 +598,11 @@ class AgentFunctionApp(DFAppBase):
THREAD_ID_FIELD: thread_id,
"status": "success",
"message_count": response_data.get("message_count", state.message_count),
"correlation_id": correlation_id,
"correlationId": correlationId,
}
def _build_request_data(
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
self, req_body: dict[str, Any], message: str, thread_id: str, correlationId: str
) -> dict[str, Any]:
"""Create the durable entity request payload."""
enable_tool_calls_value = req_body.get("enable_tool_calls")
@@ -616,17 +614,17 @@ class AgentFunctionApp(DFAppBase):
response_format=req_body.get("response_format"),
enable_tool_calls=enable_tool_calls,
thread_id=thread_id,
correlation_id=correlation_id,
correlationId=correlationId,
).to_dict()
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
def _build_accepted_response(self, message: str, thread_id: str, correlationId: str) -> dict[str, Any]:
"""Build the response returned when not waiting for completion."""
return {
"response": "Agent request accepted",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "accepted",
"correlation_id": correlation_id,
"correlationId": correlationId,
}
def _create_http_response(
@@ -712,8 +710,7 @@ class AgentFunctionApp(DFAppBase):
headers: dict[str, str] = {}
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
for key, value in header_mapping.items():
for key, value in raw_headers.items():
if value is not None:
headers[str(key).lower()] = str(value)
return headers
@@ -774,8 +771,13 @@ class AgentFunctionApp(DFAppBase):
def _should_wait_for_response(self, req: func.HttpRequest, req_body: dict[str, Any]) -> bool:
"""Determine whether the caller requested to wait for the response."""
headers: dict[str, str] = self._extract_normalized_headers(req)
header_value: str | None = headers.get(WAIT_FOR_RESPONSE_HEADER)
header_value = None
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
for key, value in raw_headers.items():
if str(key).lower() == WAIT_FOR_RESPONSE_HEADER:
header_value = value
break
if header_value is not None:
return self._coerce_to_bool(header_value)
@@ -799,4 +801,4 @@ class AgentFunctionApp(DFAppBase):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in {"true", "1", "yes", "y", "on"}
return False
return False
@@ -19,7 +19,7 @@ class AgentCallbackContext:
"""Context supplied to callback invocations."""
agent_name: str
correlation_id: str
correlationId: str
thread_id: str | None = None
request_message: str | None = None
@@ -0,0 +1,821 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from typing import Any, List, Dict, Optional, cast
from datetime import datetime, timezone
# Base content type
class DurableAgentStateContent:
extensionData: Optional[Dict]
def to_ai_content(self):
raise NotImplementedError
@staticmethod
def from_ai_content(content):
# Map AI content type to appropriate DurableAgentStateContent subclass
from agent_framework import (
DataContent, ErrorContent, FunctionCallContent, FunctionResultContent,
HostedFileContent, HostedVectorStoreContent, TextContent,
TextReasoningContent, UriContent, UsageContent
)
if isinstance(content, DataContent):
return DurableAgentStateDataContent.from_data_content(content)
elif isinstance(content, ErrorContent):
return DurableAgentStateErrorContent.from_error_content(content)
elif isinstance(content, FunctionCallContent):
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
elif isinstance(content, FunctionResultContent):
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
elif isinstance(content, HostedFileContent):
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
elif isinstance(content, HostedVectorStoreContent):
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
elif isinstance(content, TextContent):
return DurableAgentStateTextContent.from_text_content(content)
elif isinstance(content, TextReasoningContent):
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
elif isinstance(content, UriContent):
return DurableAgentStateUriContent.from_uri_content(content)
elif isinstance(content, UsageContent):
return DurableAgentStateUsageContent.from_usage_content(content)
else:
return DurableAgentStateUnknownContent.from_unknown_content(content)
# Core state classes
class DurableAgentStateData:
conversationHistory: List['DurableAgentStateEntry']
extensionData: Optional[Dict]
def __init__(self, conversationHistory=None, extensionData=None):
self.conversationHistory = conversationHistory or []
self.extensionData = extensionData
class DurableAgentState:
data: DurableAgentStateData
schema_version: str = "1.0.0"
def __init__(self, schema_version: str = "1.0.0"):
self.data = DurableAgentStateData()
self.schema_version = schema_version
def to_dict(self) -> Dict[str, Any]:
# Serialize conversationHistory
serialized_history = []
for entry in self.data.conversationHistory:
# Properly serialize each entry to a dictionary
if hasattr(entry, 'to_dict'):
serialized_history.append(entry.to_dict())
else:
# Fallback for already-serialized entries
serialized_history.append(entry)
return {
"schemaVersion": self.schema_version,
"data": {
"conversationHistory": serialized_history,
"extensionData": self.data.extensionData
},
"message_count": self.message_count,
"last_response": self.last_response,
}
def to_json(self) -> str:
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, obj: Dict[str, Any]) -> "DurableAgentState":
schema_version = obj.get("schemaVersion")
if not schema_version:
raise ValueError("The durable agent state is missing the 'schemaVersion' property.")
if not schema_version.startswith("1."):
raise ValueError(f"The durable agent state schema version '{schema_version}' is not supported.")
data_dict = obj.get("data")
if data_dict is None:
raise ValueError("The durable agent state is missing the 'data' property.")
instance = cls(schema_version=schema_version)
# Deserialize the data dict into DurableAgentStateData
if isinstance(data_dict, dict):
instance.data = DurableAgentStateData(
conversationHistory=data_dict.get("conversationHistory", []),
extensionData=data_dict.get("extensionData")
)
return instance
@classmethod
def from_json(cls, json_str: str) -> "DurableAgentState":
try:
obj = json.loads(json_str)
except json.JSONDecodeError as e:
raise ValueError("The durable agent state is not valid JSON.") from e
return cls.from_dict(obj)
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore state from a dictionary.
Args:
state: Dictionary containing schemaVersion and data (full state structure)
"""
# Extract the data portion from the state
data_dict = state.get("data", {})
# Restore the conversation history - deserialize entries from dicts to objects
history_data = data_dict.get("conversationHistory", [])
deserialized_history = []
for entry_dict in history_data:
if isinstance(entry_dict, dict):
# Deserialize based on whether it's a request or response
if "usage" in entry_dict:
deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict))
elif "response_type" in entry_dict:
deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict))
else:
deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict))
else:
# Already an object
deserialized_history.append(entry_dict)
self.data.conversationHistory = deserialized_history
self.data.extensionData = data_dict.get("extensionData")
@property
def message_count(self) -> int:
"""Get the count of conversation entries (requests + responses)."""
return len(self.data.conversationHistory)
@property
def last_response(self) -> str | None:
"""Get the text from the last assistant response in the conversation history."""
# Iterate through messages in reverse to find the last assistant message
for entry in reversed(self.data.conversationHistory):
for message in reversed(entry.messages):
if message.role == "assistant":
return message.text
return None
def add_assistant_message(self, content: str, agent_run_response, correlationId: str) -> None:
"""Add an assistant message to the conversation history.
Args:
content: The message content
agent_run_response: The agent's run response
correlationId: The correlation ID for this response
"""
# This method is called from the entity after storing the response
# The response has already been added to conversationHistory, so we don't need to do anything here
pass
def try_get_agent_response(self, correlationId: str) -> Dict[str, Any] | None:
"""Try to get an agent response by correlation ID.
Args:
correlationId: The correlation ID to search for
Returns:
Response data dict if found, None otherwise
"""
# Search through conversation history for a response with this correlationId
for entry in self.data.conversationHistory:
if hasattr(entry, 'correlationId') and entry.correlationId == correlationId:
# Found the entry, extract response data
if isinstance(entry, DurableAgentStateResponse):
# Get the text content from assistant messages only
content = ""
for message in entry.messages:
if hasattr(message, 'role') and message.role == "assistant" and hasattr(message, 'text'):
content += message.text
return {
"content": content,
"message_count": self.message_count,
"correlationId": correlationId
}
return None
# Entry classes
class DurableAgentStateEntry:
json_type: str
correlationId: str
created_at: datetime
messages: List['DurableAgentStateMessage']
extensionData: Optional[Dict]
# Request-only
responseType: Optional[str] = None
responseSchema: Optional[dict] = None
# Response-only
usage: Optional["DurableAgentStateUsage"] = None
def __init__(self, json_type, correlationId, created_at, messages, extensionData=None, responseType=None, responseSchema=None, usage=None):
self.json_type = json_type
self.correlationId = correlationId
self.created_at = created_at
self.messages = messages
self.extensionData = extensionData
self.responseType = responseType
self.responseSchema = responseSchema
self.usage = usage
def to_dict(self) -> Dict[str, Any]:
data = {
"$type": self.json_type,
"correlationId": self.correlationId,
"createdAt": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
"messages": [m.to_dict() if hasattr(m, 'to_dict') else m for m in self.messages],
"extensionData": self.extensionData
}
if self.json_type == "request":
data.update({
"responseType": self.responseType,
"responseSchema": self.responseSchema,
})
elif self.json_type == "response":
if self.usage:
data["usage"] = self.usage.to_dict()
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DurableAgentStateEntry':
from dateutil import parser as date_parser
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = date_parser.parse(created_at)
messages = []
for msg_dict in data.get("messages", []):
if isinstance(msg_dict, dict):
messages.append(DurableAgentStateMessage.from_dict(msg_dict))
else:
messages.append(msg_dict)
return cls(
correlationId=data.get("correlationId"),
created_at=created_at,
messages=messages,
extensionData=data.get("extensionData")
)
class DurableAgentStateRequest(DurableAgentStateEntry):
response_type: Optional[str] = None
response_schema: Optional[Dict] = None
def __init__(self, correlationId, created_at, messages, json_type, extensionData=None, response_type=None, response_schema=None):
self.correlationId = correlationId
self.created_at = created_at
self.messages = messages
self.json_type = json_type
self.extensionData = extensionData
self.response_type = response_type
self.response_schema = response_schema
def to_dict(self) -> Dict[str, Any]:
base_dict = super().to_dict()
base_dict["response_type"] = self.response_type
base_dict["response_schema"] = self.response_schema
return base_dict
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DurableAgentStateRequest':
from dateutil import parser as date_parser
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = date_parser.parse(created_at)
messages = []
for msg_dict in data.get("messages", []):
if isinstance(msg_dict, dict):
messages.append(DurableAgentStateMessage.from_dict(msg_dict))
else:
messages.append(msg_dict)
return cls(
json_type=data.get("$type", "request"),
correlationId=data.get("correlationId"),
created_at=created_at,
messages=messages,
extensionData=data.get("extensionData"),
response_type=data.get("response_type"),
response_schema=data.get("response_schema")
)
@staticmethod
def from_run_request(content):
from agent_framework import TextContent
return DurableAgentStateRequest(correlationId=content.correlationId,
messages=[DurableAgentStateMessage.from_chat_message(content)],
created_at=datetime.now(tz=timezone.utc),
json_type="request",
extensionData=content.extensionData if hasattr(content, 'extensionData') else None,
response_type="text" if isinstance(content.response_format, TextContent) else "json",
response_schema=content.response_format)
class DurableAgentStateResponse(DurableAgentStateEntry):
usage: Optional['DurableAgentStateUsage'] = None
def __init__(self, json_type, correlationId, created_at, messages, extensionData=None, usage=None):
self.json_type = json_type
self.correlationId = correlationId
self.created_at = created_at
self.messages = messages
self.extensionData = extensionData
self.usage = usage
def to_dict(self) -> Dict[str, Any]:
base_dict = super().to_dict()
base_dict["usage"] = self.usage.to_dict() if self.usage and hasattr(self.usage, 'to_dict') else self.usage
return base_dict
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DurableAgentStateResponse':
from dateutil import parser as date_parser
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = date_parser.parse(created_at)
messages = []
for msg_dict in data.get("messages", []):
if isinstance(msg_dict, dict):
messages.append(DurableAgentStateMessage.from_dict(msg_dict))
else:
messages.append(msg_dict)
usage_dict = data.get("usage")
usage = None
if usage_dict and isinstance(usage_dict, dict):
usage = DurableAgentStateUsage.from_dict(usage_dict)
elif usage_dict:
usage = usage_dict
return cls(
json_type=data.get("$type", "response"),
correlationId=data.get("correlationId"),
created_at=created_at,
messages=messages,
extensionData=data.get("extensionData"),
usage=usage
)
@staticmethod
def from_run_response(correlationId: str, response) -> DurableAgentStateResponse:
"""
Creates a DurableAgentStateResponse from an AgentRunResponse.
"""
# Determine the earliest created_at timestamp among messages (if available)
timestamps = [m.created_at for m in response.messages if hasattr(m, 'created_at') and m.created_at is not None]
created_at = min(timestamps) if timestamps else datetime.now(tz=timezone.utc)
return DurableAgentStateResponse(
json_type="response",
correlationId=correlationId,
created_at=created_at,
messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages],
usage=DurableAgentStateUsage.from_usage(response.usage) if hasattr(response, 'usage') and response.usage else None
)
def to_run_response(self):
"""
Converts this DurableAgentStateResponse back to an AgentRunResponse.
"""
from agent_framework import AgentRunResponse
return AgentRunResponse(
created_at=self.created_at,
messages=[m.to_chat_message() for m in self.messages],
usage=self.usage.to_usage_details() if self.usage else None
)
# Message class
class DurableAgentStateMessage:
role: str
contents: List[DurableAgentStateContent]
author_name: Optional[str] = None
created_at: Optional[datetime] = None
extensionData: Optional[Dict] = None
def __init__(self, role, contents, author_name=None, created_at=None, extensionData=None):
self.role = role
self.contents = contents
self.author_name = author_name
self.created_at = created_at
self.extensionData = extensionData
def to_dict(self) -> Dict[str, Any]:
return {
"role": self.role,
"contents": [
{"$type": c.to_dict().get("type", "text"), **{k: v for k, v in c.to_dict().items() if k != "type"}} for c in self.contents
],
"authorName": self.author_name,
"createdAt": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
"extensionData": self.extensionData
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DurableAgentStateMessage':
from dateutil import parser as date_parser
created_at = data.get("created_at")
if created_at and isinstance(created_at, str):
created_at = date_parser.parse(created_at)
contents = []
for content_dict in data.get("contents", []):
if isinstance(content_dict, dict):
content_type = content_dict.get("type")
if content_type == "text":
contents.append(DurableAgentStateTextContent(text=content_dict.get("text")))
elif content_type == "data":
contents.append(DurableAgentStateDataContent(uri=content_dict.get("uri"), media_type=content_dict.get("media_type")))
elif content_type == "error":
contents.append(DurableAgentStateErrorContent(message=content_dict.get("message"), error_code=content_dict.get("error_code"), details=content_dict.get("details")))
elif content_type == "function_call":
contents.append(DurableAgentStateFunctionCallContent(call_id=content_dict.get("call_id"), name=content_dict.get("name"), arguments=content_dict.get("arguments")))
elif content_type == "function_result":
contents.append(DurableAgentStateFunctionResultContent(call_id=content_dict.get("call_id"), result=content_dict.get("result")))
elif content_type == "hosted_file":
contents.append(DurableAgentStateHostedFileContent(file_id=content_dict.get("file_id")))
elif content_type == "hosted_vector_store":
contents.append(DurableAgentStateHostedVectorStoreContent(vector_store_id=content_dict.get("vector_store_id")))
elif content_type == "text_reasoning":
contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text")))
elif content_type == "uri":
contents.append(DurableAgentStateUriContent(uri=content_dict.get("uri"), media_type=content_dict.get("media_type")))
elif content_type == "usage":
usage_data = content_dict.get("usage")
if usage_data and isinstance(usage_data, dict):
contents.append(DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_dict(usage_data)))
elif content_type == "unknown":
contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content")))
else:
contents.append(content_dict)
return cls(
role=data.get("role"),
contents=contents,
author_name=data.get("author_name"),
created_at=created_at,
extensionData=data.get("extensionData")
)
@property
def text(self) -> str:
"""Extract text from the contents list."""
text_parts = []
for content in self.contents:
if isinstance(content, DurableAgentStateTextContent):
text_parts.append(content.text or "")
return "".join(text_parts)
@staticmethod
def from_chat_message(content):
# Convert to a list of DurableAgentStateContent objects
contents_list = []
if hasattr(content, 'message') and isinstance(content.message, str):
# RunRequest with 'message' attribute
contents_list = [DurableAgentStateTextContent(text=content.message)]
elif hasattr(content, 'contents') and content.contents:
# ChatMessage with 'contents' attribute - convert each content object
for c in content.contents:
converted = DurableAgentStateContent.from_ai_content(c)
contents_list.append(converted)
# Convert role enum to string if needed
role_value = content.role.value if hasattr(content.role, 'value') else str(content.role)
return DurableAgentStateMessage(
role=role_value,
contents=contents_list,
author_name=content.author_name if hasattr(content, 'author_name') else None,
created_at=content.created_at if hasattr(content, 'created_at') else None,
extensionData=content.extensionData if hasattr(content, 'extensionData') else None
)
def to_chat_message(self):
from agent_framework import ChatMessage
# Convert DurableAgentStateContent objects back to agent_framework content objects
ai_contents = [c.to_ai_content() for c in self.contents]
return ChatMessage(role=self.role, contents=ai_contents, author_name=self.author_name, created_at=self.created_at, extensionData=self.extensionData)
# Content subclasses
class DurableAgentStateDataContent(DurableAgentStateContent):
uri: str = ""
media_type: Optional[str] = None
def __init__(self, uri, media_type=None):
self.uri = uri
self.media_type = media_type
def to_dict(self) -> Dict[str, Any]:
return {
"type": "data",
"uri": self.uri,
"mediaType": self.media_type
}
@staticmethod
def from_data_content(content):
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type)
def to_ai_content(self):
from agent_framework import DataContent
return DataContent(uri=self.uri, media_type=self.media_type)
class DurableAgentStateErrorContent(DurableAgentStateContent):
message: Optional[str] = None
error_code: Optional[str] = None
details: Optional[str] = None
def __init__(self, message=None, error_code=None, details=None):
self.message = message
self.error_code = error_code
self.details = details
def to_dict(self) -> Dict[str, Any]:
return {
"type": "error",
"message": self.message,
"errorCode": self.error_code,
"details": self.details
}
@staticmethod
def from_error_content(content):
return DurableAgentStateErrorContent(message=content.message, error_code=content.error_code, details=content.details)
def to_ai_content(self):
from agent_framework import ErrorContent
return ErrorContent(message=self.message, error_code=self.error_code, details=self.details)
class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
call_id: str
name: str
arguments: Dict[str, object]
def __init__(self, call_id, name, arguments):
self.call_id = call_id
self.name = name
self.arguments = arguments
def to_dict(self) -> Dict[str, Any]:
return {
"type": "function_call",
"callId": self.call_id,
"name": self.name,
"arguments": self.arguments
}
@staticmethod
def from_function_call_content(content):
return DurableAgentStateFunctionCallContent(
call_id=content.call_id,
name=content.name,
arguments=content.arguments if content.arguments else {}
)
def to_ai_content(self):
from agent_framework import FunctionCallContent
return FunctionCallContent(call_id=self.call_id, name=self.name, arguments=self.arguments)
class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
call_id: str
result: Optional[object] = None
def __init__(self, call_id, result=None):
self.call_id = call_id
self.result = result
def to_dict(self) -> Dict[str, Any]:
return {
"type": "function_result",
"callId": self.call_id,
"result": self.result
}
@staticmethod
def from_function_result_content(content):
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result)
def to_ai_content(self):
from agent_framework import FunctionResultContent
return FunctionResultContent(call_id=self.call_id, result=self.result)
class DurableAgentStateHostedFileContent(DurableAgentStateContent):
file_id: str
def __init__(self, file_id):
self.file_id = file_id
def to_dict(self) -> Dict[str, Any]:
return {
"type": "hosted_file",
"fileId": self.file_id
}
@staticmethod
def from_hosted_file_content(content):
return DurableAgentStateHostedFileContent(file_id=content.file_id)
def to_ai_content(self):
from agent_framework import HostedFileContent
return HostedFileContent(file_id=self.file_id)
class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
vector_store_id: str
def __init__(self, vector_store_id):
self.vector_store_id = vector_store_id
def to_dict(self) -> Dict[str, Any]:
return {
"type": "hosted_vector_store",
"vectorStoreId": self.vector_store_id
}
@staticmethod
def from_hosted_vector_store_content(content):
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id)
def to_ai_content(self):
from agent_framework import HostedVectorStoreContent
return HostedVectorStoreContent(vector_store_id=self.vector_store_id)
class DurableAgentStateTextContent(DurableAgentStateContent):
text: Optional[str] = None
def __init__(self, text):
self.text = text
def to_dict(self) -> Dict[str, Any]:
return {
"type": "text",
"text": self.text
}
@staticmethod
def from_text_content(content):
return DurableAgentStateTextContent(text=content.text)
def to_ai_content(self):
from agent_framework import TextContent
return TextContent(text=self.text)
class DurableAgentStateTextReasoningContent(DurableAgentStateContent):
text: Optional[str] = None
def __init__(self, text):
self.text = text
def to_dict(self) -> Dict[str, Any]:
return {
"type": "text_reasoning",
"text": self.text
}
@staticmethod
def from_text_reasoning_content(content):
return DurableAgentStateTextReasoningContent(text=content.text)
def to_ai_content(self):
from agent_framework import TextReasoningContent
return TextReasoningContent(text=self.text)
class DurableAgentStateUriContent(DurableAgentStateContent):
uri: str
media_type: str
def __init__(self, uri, media_type):
self.uri = uri
self.media_type = media_type
def to_dict(self) -> Dict[str, Any]:
return {
"type": "uri",
"uri": self.uri,
"mediaType": self.media_type
}
@staticmethod
def from_uri_content(content):
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type)
def to_ai_content(self):
from agent_framework import UriContent
return UriContent(uri=self.uri, media_type=self.media_type)
class DurableAgentStateUsage:
input_token_count: Optional[int] = None
output_token_count: Optional[int] = None
total_token_count: Optional[int] = None
extensionData: Optional[Dict] = None
def __init__(self, input_token_count=None, output_token_count=None, total_token_count=None, extensionData=None):
self.input_token_count = input_token_count
self.output_token_count = output_token_count
self.total_token_count = total_token_count
self.extensionData = extensionData
def to_dict(self) -> Dict[str, Any]:
return {
"inputTokenCount": self.input_token_count,
"outputTokenCount": self.output_token_count,
"totalTokenCount": self.total_token_count,
"extensionData": self.extensionData
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DurableAgentStateUsage':
return cls(
input_token_count=data.get("input_token_count"),
output_token_count=data.get("output_token_count"),
total_token_count=data.get("total_token_count"),
extensionData=data.get("extensionData")
)
@staticmethod
def from_usage(usage):
if usage is None:
return None
return DurableAgentStateUsage(
input_token_count=usage.input_token_count,
output_token_count=usage.output_token_count,
total_token_count=usage.total_token_count
)
def to_usage_details(self):
# Convert back to AI SDK UsageDetails
from agent_framework import UsageDetails
return UsageDetails(
input_token_count=self.input_token_count,
output_token_count=self.output_token_count,
total_token_count=self.total_token_count
)
class DurableAgentStateUsageContent(DurableAgentStateContent):
usage: DurableAgentStateUsage = DurableAgentStateUsage()
def __init__(self, usage):
self.usage = usage
def to_dict(self) -> Dict[str, Any]:
return {
"type": "usage",
"usage": self.usage.to_dict() if hasattr(self.usage, 'to_dict') else self.usage
}
@staticmethod
def from_usage_content(content):
return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.details))
def to_ai_content(self):
from agent_framework import UsageContent
return UsageContent(details=self.usage.to_usage_details())
class DurableAgentStateUnknownContent(DurableAgentStateContent):
content: dict
def __init__(self, content):
self.content = content
def to_dict(self) -> Dict[str, Any]:
return {
"type": "unknown",
"content": self.content
}
@staticmethod
def from_unknown_content(content):
return DurableAgentStateUnknownContent(content=json.loads(content))
def to_ai_content(self):
from agent_framework import BaseContent
if not self.content:
raise Exception(f"The content is missing and cannot be converted to valid AI content.")
return BaseContent(content=json.loads(self.content))
@@ -10,15 +10,21 @@ allows for long-running agent conversations.
import asyncio
import inspect
import json
from collections.abc import AsyncIterable, Callable
from typing import Any, cast
from collections.abc import AsyncIterable
from datetime import datetime, timezone
from typing import Any, cast, Callable
import azure.durable_functions as df
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import AgentResponse, RunRequest
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions.entities")
@@ -38,11 +44,11 @@ class AgentEntity:
Attributes:
agent: The AgentProtocol instance
state: The AgentState managing conversation history
state: The DurableAgentState managing conversation history
"""
agent: AgentProtocol
state: AgentState
state: DurableAgentState
def __init__(
self,
@@ -56,8 +62,9 @@ class AgentEntity:
callback: Optional callback invoked during streaming updates and final responses
"""
self.agent = agent
self.state = AgentState()
self.state = DurableAgentState()
self.callback = callback
self._pending_requests: dict[str, DurableAgentStateRequest] = {}
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
@@ -89,31 +96,49 @@ class AgentEntity:
message = run_request.message
thread_id = run_request.thread_id
correlation_id = run_request.correlation_id
correlationId = run_request.correlationId
if not thread_id:
raise ValueError("RunRequest must include a thread_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
if not correlationId:
raise ValueError("RunRequest must include a correlationId")
role = run_request.role or Role.USER
response_format = run_request.response_format
enable_tool_calls = run_request.enable_tool_calls
# Store request in pending (will be combined with response later)
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversationHistory.append(state_request)
self._pending_requests[correlationId] = state_request
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlationId}")
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
# Store message in history with role
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
logger.debug(f"[AgentEntity.run_agent] Saved state request: {state_request}")
logger.debug("[AgentEntity.run_agent] Executing agent...")
try:
logger.debug("[AgentEntity.run_agent] Starting agent invocation")
run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()}
# Build messages from conversation history plus the current request
chat_messages = [
m.to_chat_message()
for entry in self.state.data.conversationHistory
for m in entry.messages
]
# Add the current request message
# for m in state_request.messages:
# chat_messages.append(m.to_chat_message())
# Strip additional_properties from all messages to avoid metadata being sent to Azure OpenAI
# Azure OpenAI doesn't support the 'metadata' field in messages
for msg in chat_messages:
if hasattr(msg, 'additional_properties'):
msg.additional_properties = {}
run_kwargs: dict[str, Any] = {"messages": chat_messages}
if not enable_tool_calls:
run_kwargs["tools"] = None
if response_format:
@@ -121,7 +146,7 @@ class AgentEntity:
agent_run_response: AgentRunResponse = await self._invoke_agent(
run_kwargs=run_kwargs,
correlation_id=correlation_id,
correlationId=correlationId,
thread_id=thread_id,
request_message=message,
)
@@ -131,6 +156,17 @@ class AgentEntity:
type(agent_run_response).__name__,
)
# Convert response into DurableAgentStateResponse and combine with request
state_response = DurableAgentStateResponse.from_run_response(correlationId, agent_run_response)
# Get the pending request and combine its messages with the response messages
# pending_request = self._pending_requests.pop(correlationId, None)
# if pending_request:
# # Combine request and response messages into a single entry
# state_response.messages = pending_request.messages + state_response.messages
self.state.data.conversationHistory.append(state_response)
response_text = None
structured_response = None
@@ -161,13 +197,13 @@ class AgentEntity:
message=str(message),
thread_id=str(thread_id),
status="success",
message_count=self.state.message_count,
message_count=len(self.state.data.conversationHistory),
structured_response=structured_response,
)
result = agent_response.to_dict()
content = json.dumps(structured_response) if structured_response else (response_text or "")
self.state.add_assistant_message(content, agent_run_response, correlation_id)
self.state.add_assistant_message(content, agent_run_response, correlationId)
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
return result
@@ -181,12 +217,39 @@ class AgentEntity:
logger.error(f"Error type: {type(exc).__name__}")
logger.error(f"Full traceback:\n{error_traceback}")
# Create error response and store it in conversation history so polling can find it
from agent_framework import ChatMessage, ErrorContent
# Get the pending request
pending_request = self._pending_requests.pop(correlationId, None)
# Create error message
error_message = DurableAgentStateMessage.from_chat_message(
ChatMessage(role="assistant", contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)])
)
# Combine request and error response messages
messages = []
if pending_request:
messages.extend(pending_request.messages)
messages.append(error_message)
# Create and store error response in conversation history
error_state_response = DurableAgentStateResponse(
correlationId=correlationId,
createdAt=datetime.now(tz=timezone.utc),
messages=messages,
extensionData=None,
usage=None
)
self.state.data.conversationHistory.append(error_state_response)
error_response = AgentResponse(
response=f"Error: {exc!s}",
message=str(message),
thread_id=str(thread_id),
status="error",
message_count=self.state.message_count,
message_count=len(self.state.data.conversationHistory),
error=str(exc),
error_type=type(exc).__name__,
)
@@ -195,7 +258,7 @@ class AgentEntity:
async def _invoke_agent(
self,
run_kwargs: dict[str, Any],
correlation_id: str,
correlationId: str,
thread_id: str,
request_message: str,
) -> AgentRunResponse:
@@ -203,7 +266,7 @@ class AgentEntity:
callback_context: AgentCallbackContext | None = None
if self.callback is not None:
callback_context = self._build_callback_context(
correlation_id=correlation_id,
correlationId=correlationId,
thread_id=thread_id,
request_message=request_message,
)
@@ -317,7 +380,7 @@ class AgentEntity:
def _build_callback_context(
self,
correlation_id: str,
correlationId: str,
thread_id: str,
request_message: str,
) -> AgentCallbackContext:
@@ -325,7 +388,7 @@ class AgentEntity:
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
return AgentCallbackContext(
agent_name=agent_name,
correlation_id=correlation_id,
correlationId=correlationId,
thread_id=thread_id,
request_message=request_message,
)
@@ -333,7 +396,7 @@ class AgentEntity:
def reset(self, context: df.DurableEntityContext) -> None:
"""Reset the entity state (clear conversation history)."""
logger.debug("[AgentEntity.reset] Resetting entity state")
self.state.reset()
self.state.data = DurableAgentStateData(conversationHistory=[])
logger.debug("[AgentEntity.reset] State reset complete")
@@ -392,8 +455,9 @@ def create_agent_entity(
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {operation}"})
logger.info("State dict: %s", str(entity.state.to_dict()))
context.set_state(entity.state.to_dict())
logger.debug(f"[entity_function] Operation {operation} completed successfully")
logger.info(f"[entity_function] Operation {operation} completed successfully")
except Exception as exc:
import traceback
@@ -424,4 +488,4 @@ def create_agent_entity(
logger.error("[entity_function] Unexpected error executing entity: %s", exc, exc_info=True)
context.set_result({"error": str(exc), "status": "error"})
return entity_function
return entity_function
@@ -282,7 +282,7 @@ class RunRequest:
response_format: Optional Pydantic BaseModel type describing the structured response format
enable_tool_calls: Whether to enable tool calls for this request
thread_id: Optional thread ID for tracking
correlation_id: Optional correlation ID for tracking the response to this specific request
correlationId: Optional correlation ID for tracking the response to this specific request
"""
message: str
@@ -290,7 +290,10 @@ class RunRequest:
response_format: type[BaseModel] | None = None
enable_tool_calls: bool = True
thread_id: str | None = None
correlation_id: str | None = None
correlationId: str | None = None
author_name: str | None = None
created_at: str | None = None
extension_data: dict[str, Any] | None = None
def __init__(
self,
@@ -299,14 +302,14 @@ class RunRequest:
response_format: type[BaseModel] | None = None,
enable_tool_calls: bool = True,
thread_id: str | None = None,
correlation_id: str | None = None,
correlationId: str | None = None,
) -> None:
self.message = message
self.role = self.coerce_role(role)
self.response_format = response_format
self.enable_tool_calls = enable_tool_calls
self.thread_id = thread_id
self.correlation_id = correlation_id
self.correlationId = correlationId
@staticmethod
def coerce_role(value: Role | str | None) -> Role:
@@ -331,8 +334,14 @@ class RunRequest:
result["response_format"] = _serialize_response_format(self.response_format)
if self.thread_id:
result["thread_id"] = self.thread_id
if self.correlation_id:
result["correlation_id"] = self.correlation_id
if self.correlationId:
result["correlationId"] = self.correlationId
if self.author_name:
result["author_name"] = self.author_name
if self.created_at:
result["created_at"] = self.created_at
if self.extension_data:
result["extension_data"] = self.extension_data
return result
@classmethod
@@ -344,7 +353,7 @@ class RunRequest:
response_format=_deserialize_response_format(data.get("response_format")),
enable_tool_calls=data.get("enable_tool_calls", True),
thread_id=data.get("thread_id"),
correlation_id=data.get("correlation_id"),
correlationId=data.get("correlationId"),
)
@@ -392,4 +401,4 @@ class AgentResponse:
if self.error_type:
result["error_type"] = self.error_type
return result
return result
@@ -129,13 +129,13 @@ class DurableAIAgent(AgentProtocol):
# Generate a deterministic correlation ID for this call
# This is required by the entity and must be unique per call
correlation_id = str(self.context.new_uuid())
correlationId = str(self.context.new_uuid())
# Prepare the request using RunRequest model
run_request = RunRequest(
message=message_str,
enable_tool_calls=enable_tool_calls,
correlation_id=correlation_id,
correlationId=correlationId,
thread_id=session_id.key,
response_format=response_format,
)
@@ -1,179 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent State Management.
This module defines the AgentState class for managing conversation state and
serializing agent framework responses.
"""
from collections.abc import MutableMapping
from datetime import datetime, timezone
from typing import Any, cast
from agent_framework import AgentRunResponse, ChatMessage, Role, get_logger
logger = get_logger("agent_framework.azurefunctions.state")
class AgentState:
"""Manages agent conversation state using agent_framework types (ChatMessage, AgentRunResponse).
This class handles:
- Conversation history tracking using ChatMessage objects
- Agent response storage using AgentRunResponse objects with correlation IDs
- State persistence and restoration
- Message counting
"""
def __init__(self) -> None:
"""Initialize empty agent state."""
self.conversation_history: list[ChatMessage] = []
self.last_response: str | None = None
self.message_count: int = 0
def _current_timestamp(self) -> str:
"""Return an ISO 8601 UTC timestamp."""
return datetime.now(timezone.utc).isoformat()
def add_user_message(
self,
content: str,
role: Role = Role.USER,
correlation_id: str | None = None,
) -> None:
"""Add a user message to the conversation history as a ChatMessage object.
Args:
content: The message content
role: The message role (user, system, etc.)
correlation_id: Optional correlation identifier associated with the user message
"""
self.message_count += 1
timestamp = self._current_timestamp()
additional_props: MutableMapping[str, Any] = {"timestamp": timestamp}
if correlation_id is not None:
additional_props["correlation_id"] = correlation_id
chat_message = ChatMessage(role=role, text=content, additional_properties=additional_props)
self.conversation_history.append(chat_message)
logger.debug(f"Added {role} ChatMessage to history (message #{self.message_count})")
def add_assistant_message(
self, content: str, agent_response: AgentRunResponse, correlation_id: str | None = None
) -> None:
"""Add an assistant message to the conversation history with full agent response.
Args:
content: The text content of the response
agent_response: The AgentRunResponse object from the agent framework
correlation_id: Optional correlation ID for tracking this response
"""
self.last_response = content
timestamp = self._current_timestamp()
serialized_response = self.serialize_response(agent_response)
# Create a ChatMessage for the assistant response
# The agent_response already contains messages, but we store it as a custom ChatMessage
# with the agent_response stored in additional_properties for full metadata preservation
additional_props: dict[str, Any] = {
"agent_response": serialized_response,
"correlation_id": correlation_id,
"timestamp": timestamp,
"message_count": self.message_count,
}
chat_message = ChatMessage(role="assistant", text=content, additional_properties=additional_props)
self.conversation_history.append(chat_message)
logger.debug(
f"Added assistant ChatMessage to history with AgentRunResponse metadata (correlation_id: {correlation_id})"
)
def get_chat_messages(self) -> list[ChatMessage]:
"""Return a copy of the full conversation history."""
return list(self.conversation_history)
def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None:
"""Get an agent response by correlation ID.
Args:
correlation_id: The correlation ID to look up
Returns:
The agent response data if found, None otherwise
"""
for message in reversed(self.conversation_history):
metadata = getattr(message, "additional_properties", {}) or {}
if metadata.get("correlation_id") == correlation_id:
return self._build_agent_response_payload(message, metadata)
return None
def serialize_response(self, response: AgentRunResponse) -> dict[str, Any]:
"""Serialize an ``AgentRunResponse`` to a dictionary.
Args:
response: The agent framework response object
Returns:
Dictionary containing all response fields
"""
try:
return response.to_dict()
except Exception as exc: # pragma: no cover - defensive logging path
logger.warning(f"Error serializing response: {exc}")
return {"response": str(response), "serialization_error": str(exc)}
def to_dict(self) -> dict[str, Any]:
"""Get the current state as a dictionary for persistence.
Returns:
Dictionary containing conversation_history (as serialized ChatMessages),
last_response, and message_count
"""
return {
"conversation_history": [msg.to_dict() for msg in self.conversation_history],
"last_response": self.last_response,
"message_count": self.message_count,
}
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore state from a dictionary, reconstructing ChatMessage objects.
Args:
state: Dictionary containing conversation_history, last_response, and message_count
"""
# Restore conversation history as ChatMessage objects
history_data = state.get("conversation_history", [])
restored_history: list[ChatMessage] = []
for raw_message in history_data:
if isinstance(raw_message, dict):
restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message)))
else:
restored_history.append(cast(ChatMessage, raw_message))
self.conversation_history = restored_history
self.last_response = state.get("last_response")
self.message_count = state.get("message_count", 0)
logger.debug("Restored state: %s ChatMessages in history", len(self.conversation_history))
def reset(self) -> None:
"""Reset the state to empty."""
self.conversation_history = []
self.last_response = None
self.message_count = 0
logger.debug("State reset to empty")
def __repr__(self) -> str:
"""String representation of the state."""
return f"AgentState(messages={self.message_count}, history_length={len(self.conversation_history)})"
def _build_agent_response_payload(self, message: ChatMessage, metadata: dict[str, Any]) -> dict[str, Any]:
"""Construct the agent response payload returned to callers."""
return {
"content": message.text,
"agent_response": metadata.get("agent_response"),
"message_count": metadata.get("message_count", self.message_count),
"timestamp": metadata.get("timestamp"),
"correlation_id": metadata.get("correlation_id"),
}
+11 -2
View File
@@ -60,8 +60,17 @@ class MyChatKitServer(ChatKitServer[dict[str, Any]]):
if input_user_message is None:
return
# Convert ChatKit message to Agent Framework format
agent_messages = await simple_to_agent_input(input_user_message)
# Load full thread history to maintain conversation context
thread_items_page = await self.store.load_thread_items(
thread_id=thread.id,
after=None,
limit=1000,
order="asc",
context=context,
)
# Convert all ChatKit messages to Agent Framework format
agent_messages = await simple_to_agent_input(thread_items_page.data)
# Run the agent and stream responses
response_stream = agent.run_stream(agent_messages)
@@ -781,6 +781,27 @@ class AgentFrameworkExecutor:
Returns:
Dict of {request_id: response_value} if found, None otherwise
"""
# Handle case where input_data might be a JSON string (from streamWorkflowExecutionOpenAI)
# The input field type is: str | list[Any] | dict[str, Any]
if isinstance(input_data, str):
try:
parsed = json.loads(input_data)
# Only use parsed value if it's a list (ResponseInputParam format expected for HIL)
if isinstance(parsed, list):
input_data = parsed
else:
# Parsed to dict, string, or primitive - not HIL response format
return None
except (json.JSONDecodeError, TypeError):
# Plain text string, not valid JSON - not HIL format
return None
# At this point, input_data should be a list or dict
# HIL responses are always in list format (ResponseInputParam)
if isinstance(input_data, dict):
# This is structured workflow input (dict), not HIL responses
return None
if not isinstance(input_data, list):
return None
@@ -261,6 +261,27 @@ def test_executor_parse_stringified_json_workflow_input():
assert parsed.metadata == {"key": "value"}
def test_extract_workflow_hil_responses_handles_stringified_json():
"""Test HIL response extraction handles both stringified and parsed JSON (regression test)."""
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor
from agent_framework_devui._mapper import MessageMapper
executor = AgentFrameworkExecutor(EntityDiscovery(None), MessageMapper())
# Regression test: Frontend sends stringified JSON via streamWorkflowExecutionOpenAI
stringified = '[{"type":"message","content":[{"type":"workflow_hil_response","responses":{"req_1":"spam"}}]}]'
assert executor._extract_workflow_hil_responses(stringified) == {"req_1": "spam"}
# Ensure parsed format still works
parsed = [{"type": "message", "content": [{"type": "workflow_hil_response", "responses": {"req_2": "ham"}}]}]
assert executor._extract_workflow_hil_responses(parsed) == {"req_2": "ham"}
# Non-HIL inputs should return None
assert executor._extract_workflow_hil_responses("plain text") is None
assert executor._extract_workflow_hil_responses({"email": "test"}) is None
async def test_executor_handles_non_streaming_agent():
"""Test executor can handle agents with only run() method (no run_stream)."""
from agent_framework import AgentRunResponse, AgentThread, ChatMessage, Role, TextContent
+7 -10
View File
@@ -280,18 +280,14 @@ async def test_api_restrictions_in_user_mode():
assert dev_client.get("/v1/entities").status_code == 200
assert user_client.get("/v1/entities").status_code == 200
# Test 4: Entity info should be restricted in user mode
# Test 4: Entity info should be accessible in both modes (UI needs this)
dev_response = dev_client.get("/v1/entities/test_agent/info")
assert dev_response.status_code in [200, 404, 500] # Not 403
user_response = user_client.get("/v1/entities/test_agent/info")
assert user_response.status_code == 403
error_data = user_response.json()
# FastAPI wraps HTTPException detail in 'detail' field
error = error_data.get("detail", {}).get("error") or error_data.get("error")
assert error is not None
assert "developer mode" in error["message"].lower()
assert error["code"] == "developer_mode_required"
# Should return 404 (entity doesn't exist) or 500 (other error), but NOT 403 (forbidden)
# User mode needs entity info to display workflows/agents in the UI
assert user_response.status_code in [200, 404, 500] # Not 403
# Test 5: Hot reload should be restricted in user mode
dev_response = dev_client.post("/v1/entities/test_agent/reload")
@@ -329,10 +325,11 @@ async def test_api_restrictions_in_user_mode():
# Test 8: Chat endpoint should work in both modes
chat_payload = {"model": "test_agent", "input": "Hello"}
dev_response = dev_client.post("/v1/responses", json=chat_payload)
assert dev_response.status_code in [200, 404] # 404 if agent doesn't exist
# 200=success, 400=missing entity_id in metadata, 404=entity not found
assert dev_response.status_code in [200, 400, 404]
user_response = user_client.post("/v1/responses", json=chat_payload)
assert user_response.status_code in [200, 404]
assert user_response.status_code in [200, 400, 404]
if __name__ == "__main__":
+1
View File
@@ -55,6 +55,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
| [`getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py`](./getting_started/agents/azure_openai/azure_responses_client_with_code_interpreter.py) | Azure OpenAI Responses Client with Code Interpreter Example |
| [`getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py`](./getting_started/agents/azure_openai/azure_responses_client_with_explicit_settings.py) | Azure OpenAI Responses Client with Explicit Settings Example |
| [`getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py`](./getting_started/agents/azure_openai/azure_responses_client_with_function_tools.py) | Azure OpenAI Responses Client with Function Tools Example |
| [`getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py`](./getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py) | Azure OpenAI Responses Client with Hosted Model Context Protocol (MCP) Example |
| [`getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py`](./getting_started/agents/azure_openai/azure_responses_client_with_local_mcp.py) | Azure OpenAI Responses Client with local Model Context Protocol (MCP) Example |
| [`getting_started/agents/azure_openai/azure_responses_client_with_thread.py`](./getting_started/agents/azure_openai/azure_responses_client_with_thread.py) | Azure OpenAI Responses Client with Thread Management Example |
+124 -33
View File
@@ -16,11 +16,43 @@ from random import randint
from typing import Annotated, Any
import uvicorn
# Agent Framework imports
from agent_framework import AgentRunResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role
from agent_framework.azure import AzureOpenAIChatClient
# Agent Framework ChatKit integration
from agent_framework_chatkit import ThreadItemConverter, stream_agent_response
# Local imports
from attachment_store import FileBasedAttachmentStore
from azure.identity import AzureCliCredential
# ChatKit imports
from chatkit.actions import Action
from chatkit.server import ChatKitServer
from chatkit.store import StoreItemType, default_generate_id
from chatkit.types import (
ThreadItem,
ThreadItemDoneEvent,
ThreadMetadata,
ThreadStreamEvent,
UserMessageItem,
WidgetItem,
)
from chatkit.widgets import WidgetRoot
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
from pydantic import Field
from store import SQLiteStore
from weather_widget import (
WeatherData,
city_selector_copy_text,
render_city_selector_widget,
render_weather_widget,
weather_widget_copy_text,
)
# ============================================================================
# Configuration Constants
@@ -56,37 +88,6 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# Agent Framework imports
from agent_framework import AgentRunResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role
from agent_framework.azure import AzureOpenAIChatClient
# Agent Framework ChatKit integration
from agent_framework_chatkit import ThreadItemConverter, stream_agent_response
# Local imports
from attachment_store import FileBasedAttachmentStore
# ChatKit imports
from chatkit.actions import Action
from chatkit.server import ChatKitServer
from chatkit.store import StoreItemType, default_generate_id
from chatkit.types import (
ThreadItemDoneEvent,
ThreadMetadata,
ThreadStreamEvent,
UserMessageItem,
WidgetItem,
)
from chatkit.widgets import WidgetRoot
from store import SQLiteStore
from weather_widget import (
WeatherData,
city_selector_copy_text,
render_city_selector_widget,
render_weather_widget,
weather_widget_copy_text,
)
class WeatherResponse(str):
"""A string response that also carries WeatherData for widget creation."""
@@ -238,6 +239,81 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
"""
return await attachment_store.read_attachment_bytes(attachment_id)
async def _update_thread_title(
self, thread: ThreadMetadata, thread_items: list[ThreadItem], context: dict[str, Any]
) -> None:
"""Update thread title using LLM to generate a concise summary.
Args:
thread: The thread metadata to update.
thread_items: All items in the thread.
context: The context dictionary.
"""
logger.info(f"Attempting to update thread title for thread: {thread.id}")
if not thread_items:
logger.debug("No thread items available for title generation")
return
# Collect user messages to understand the conversation topic
user_messages: list[str] = []
for item in thread_items:
if isinstance(item, UserMessageItem) and item.content:
for content_part in item.content:
if hasattr(content_part, "text") and isinstance(content_part.text, str):
user_messages.append(content_part.text)
break
if not user_messages:
logger.debug("No user messages found for title generation")
return
logger.debug(f"Found {len(user_messages)} user message(s) for title generation")
try:
# Use the agent's chat client to generate a concise title
# Combine first few messages to capture the conversation topic
conversation_context = "\n".join(user_messages[:3])
title_prompt = [
ChatMessage(
role=Role.USER,
text=(
f"Generate a very short, concise title (max 40 characters) for a conversation "
f"that starts with:\n\n{conversation_context}\n\n"
"Respond with ONLY the title, nothing else."
),
)
]
# Use the chat client directly for a quick, lightweight call
response = await self.weather_agent.chat_client.get_response(
messages=title_prompt,
temperature=0.3,
max_tokens=20,
)
if response.messages and response.messages[-1].text:
title = response.messages[-1].text.strip().strip('"').strip("'")
# Ensure it's not too long
if len(title) > 50:
title = title[:47] + "..."
thread.title = title
await self.store.save_thread(thread, context)
logger.info(f"Updated thread {thread.id} title to: {title}")
except Exception as e:
logger.warning(f"Failed to generate thread title, using fallback: {e}")
# Fallback to simple truncation
first_message: str = user_messages[0]
title: str = first_message[:50].strip()
if len(first_message) > 50:
title += "..."
thread.title = title
await self.store.save_thread(thread, context)
logger.info(f"Updated thread {thread.id} title to (fallback): {title}")
async def respond(
self,
thread: ThreadMetadata,
@@ -263,8 +339,19 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
weather_data: WeatherData | None = None
show_city_selector = False
# Convert ChatKit user message to Agent Framework ChatMessage using ThreadItemConverter
agent_messages = await self.converter.to_agent_input(input_user_message)
# Load full thread history from the store
thread_items_page = await self.store.load_thread_items(
thread_id=thread.id,
after=None,
limit=1000,
order="asc",
context=context,
)
thread_items = thread_items_page.data
# Convert ALL thread items to Agent Framework ChatMessages using ThreadItemConverter
# This ensures the agent has the full conversation context
agent_messages = await self.converter.to_agent_input(thread_items)
if not agent_messages:
logger.warning("No messages after conversion")
@@ -330,6 +417,10 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
yield widget_event
logger.debug("City selector widget streamed successfully")
# Update thread title based on first user message if not already set
if not thread.title or thread.title == "New thread":
await self._update_thread_title(thread, thread_items, context)
logger.info(f"Completed processing message for thread: {thread.id}")
except Exception as e:
@@ -8,7 +8,7 @@ cloud storage like S3, Azure Blob Storage, or Google Cloud Storage.
"""
from pathlib import Path
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from chatkit.store import AttachmentStore
from chatkit.types import Attachment, AttachmentCreateParams, FileAttachment, ImageAttachment
@@ -51,7 +51,7 @@ class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
self.uploads_dir = Path(uploads_dir)
self.base_url = base_url.rstrip("/")
self.data_store = data_store
# Create uploads directory if it doesn't exist
self.uploads_dir.mkdir(parents=True, exist_ok=True)
@@ -65,9 +65,7 @@ class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
if file_path.exists():
file_path.unlink()
async def create_attachment(
self, input: AttachmentCreateParams, context: dict[str, Any]
) -> Attachment:
async def create_attachment(self, input: AttachmentCreateParams, context: dict[str, Any]) -> Attachment:
"""Create an attachment with upload URL for two-phase upload.
This creates the attachment metadata and returns upload URLs that
@@ -75,7 +73,7 @@ class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
"""
# Generate unique ID for this attachment
attachment_id = self.generate_attachment_id(input.mime_type, context)
# Generate upload URL that points to our FastAPI upload endpoint
upload_url = f"{self.base_url}/upload/{attachment_id}"
@@ -83,7 +81,7 @@ class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
if input.mime_type.startswith("image/"):
# For images, also provide a preview URL
preview_url = f"{self.base_url}/preview/{attachment_id}"
attachment = ImageAttachment(
id=attachment_id,
type="image",
@@ -117,5 +115,5 @@ class FileBasedAttachmentStore(AttachmentStore[dict[str, Any]]):
file_path = self.get_file_path(attachment_id)
if not file_path.exists():
raise FileNotFoundError(f"Attachment {attachment_id} not found on disk")
return file_path.read_bytes()
@@ -10,7 +10,7 @@ import sqlite3
import uuid
from typing import Any
from chatkit.store import Store, NotFoundError
from chatkit.store import NotFoundError, Store
from chatkit.types import (
Attachment,
Page,
@@ -22,16 +22,19 @@ from pydantic import BaseModel
class ThreadData(BaseModel):
"""Model for serializing thread data to SQLite."""
thread: ThreadMetadata
class ItemData(BaseModel):
"""Model for serializing thread item data to SQLite."""
item: ThreadItem
class AttachmentData(BaseModel):
"""Model for serializing attachment data to SQLite."""
attachment: Attachment
@@ -185,19 +188,13 @@ class SQLiteStore(Store[dict[str, Any]]):
params.append(limit + 1)
items_cursor = conn.execute(query, params).fetchall()
items = [
ItemData.model_validate_json(row[0]).item for row in items_cursor
]
items = [ItemData.model_validate_json(row[0]).item for row in items_cursor]
has_more = len(items) > limit
if has_more:
items = items[:limit]
return Page[ThreadItem](
data=items,
has_more=has_more,
after=items[-1].id if items else None
)
return Page[ThreadItem](data=items, has_more=has_more, after=items[-1].id if items else None)
async def save_attachment(self, attachment: Attachment, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
@@ -270,23 +267,15 @@ class SQLiteStore(Store[dict[str, Any]]):
params.append(limit + 1)
threads_cursor = conn.execute(query, params).fetchall()
threads = [
ThreadData.model_validate_json(row[0]).thread for row in threads_cursor
]
threads = [ThreadData.model_validate_json(row[0]).thread for row in threads_cursor]
has_more = len(threads) > limit
if has_more:
threads = threads[:limit]
return Page[ThreadMetadata](
data=threads,
has_more=has_more,
after=threads[-1].id if threads else None
)
return Page[ThreadMetadata](data=threads, has_more=has_more, after=threads[-1].id if threads else None)
async def add_thread_item(
self, thread_id: str, item: ThreadItem, context: dict[str, Any]
) -> None:
async def add_thread_item(self, thread_id: str, item: ThreadItem, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
@@ -348,9 +337,7 @@ class SQLiteStore(Store[dict[str, Any]]):
)
conn.commit()
async def delete_thread_item(
self, thread_id: str, item_id: str, context: dict[str, Any]
) -> None:
async def delete_thread_item(self, thread_id: str, item_id: str, context: dict[str, Any]) -> None:
user_id = context.get("user_id", "demo_user")
with self._create_connection() as conn:
@@ -29,7 +29,6 @@ POPULAR_CITIES = [
CITY_VALUE_TO_NAME = {city["value"]: city["label"] for city in POPULAR_CITIES}
def _sun_svg() -> str:
"""Generate SVG for sunny weather icon."""
color = WEATHER_ICON_COLOR
@@ -9,6 +9,8 @@ This folder contains examples demonstrating different ways to create and use age
| [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `AzureAIClient`. Demonstrates both streaming and non-streaming responses with function tools. Shows automatic agent creation and basic weather functionality. |
| [`azure_ai_use_latest_version.py`](azure_ai_use_latest_version.py) | Demonstrates how to reuse the latest version of an existing agent instead of creating a new agent version on each instantiation using the `use_latest_version=True` parameter. |
| [`azure_ai_with_azure_ai_search.py`](azure_ai_with_azure_ai_search.py) | Shows how to use Azure AI Search with Azure AI agents to search through indexed data and answer user questions with proper citations. Requires an Azure AI Search connection and index configured in your Azure AI project. |
| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to search the web for current information and provide grounded responses with citations. Requires a Bing connection configured in your Azure AI project. |
| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to search custom search instances and provide responses with relevant results. Requires a Bing Custom Search connection and instance configured in your Azure AI project. |
| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the `HostedCodeInterpreterTool` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. |
| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. |
| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentThread with an existing conversation ID. |
@@ -0,0 +1,48 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework.azure import AzureAIClient
from azure.identity.aio import AzureCliCredential
"""
Azure AI Agent with Bing Custom Search Example
This sample demonstrates usage of AzureAIClient with Bing Custom Search
to search custom search instances and provide responses with relevant results.
Prerequisites:
1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables.
2. Ensure you have a Bing Custom Search connection configured in your Azure AI project
and set BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID and BING_CUSTOM_SEARCH_INSTANCE_NAME environment variables.
"""
async def main() -> None:
async with (
AzureCliCredential() as credential,
AzureAIClient(async_credential=credential).create_agent(
name="MyCustomSearchAgent",
instructions="""You are a helpful agent that can use Bing Custom Search tools to assist users.
Use the available Bing Custom Search tools to answer questions and perform tasks.""",
tools={
"type": "bing_custom_search",
"bing_custom_search": {
"search_configurations": [
{
"project_connection_id": os.environ["BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID"],
"instance_name": os.environ["BING_CUSTOM_SEARCH_INSTANCE_NAME"],
}
]
},
},
) as agent,
):
query = "Tell me more about foundry agent service"
print(f"User: {query}")
result = await agent.run(query)
print(f"Result: {result}\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework.azure import AzureAIClient
from azure.identity.aio import AzureCliCredential
"""
Azure AI Agent with Bing Grounding Example
This sample demonstrates usage of AzureAIClient with Bing Grounding
to search the web for current information and provide grounded responses.
Prerequisites:
1. Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME environment variables.
2. Ensure you have a Bing connection configured in your Azure AI project
and set BING_PROJECT_CONNECTION_ID environment variable.
To get your Bing connection ID:
- Go to Azure AI Foundry portal (https://ai.azure.com)
- Navigate to your project's "Connected resources" section
- Add a new connection for "Grounding with Bing Search"
- Copy the connection ID and set it as the BING_PROJECT_CONNECTION_ID environment variable
"""
async def main() -> None:
async with (
AzureCliCredential() as credential,
AzureAIClient(async_credential=credential).create_agent(
name="MyBingGroundingAgent",
instructions="""You are a helpful assistant that can search the web for current information.
Use the Bing search tool to find up-to-date information and provide accurate, well-sourced answers.
Always cite your sources when possible.""",
tools={
"type": "bing_grounding",
"bing_grounding": {
"search_configurations": [
{
"project_connection_id": os.environ["BING_PROJECT_CONNECTION_ID"],
}
]
},
},
) as agent,
):
query = "What is today's date and weather in Seattle?"
print(f"User: {query}")
result = await agent.run(query)
print(f"Result: {result}\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -103,11 +103,11 @@ async def main() -> None:
print()
# Display collected citations
# Display collected citation
if citations:
print("\n\nCitations:")
print("\n\nCitation:")
for i, citation in enumerate(citations, 1):
print(f"[{i}] Reference: {citation.url}")
print(f"[{i}] {citation.url}")
print("\n" + "=" * 50 + "\n")
print("Hotel search conversation completed!")
@@ -0,0 +1,240 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import TYPE_CHECKING, Any
from agent_framework import ChatAgent, HostedMCPTool
from agent_framework.azure import AzureOpenAIResponsesClient
from azure.identity import AzureCliCredential
"""
Azure OpenAI Responses Client with Hosted MCP Example
This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with
Azure OpenAI Responses Client, including user approval workflows for function call security.
"""
if TYPE_CHECKING:
from agent_framework import AgentProtocol, AgentThread
async def handle_approvals_without_thread(query: str, agent: "AgentProtocol"):
"""When we don't have a thread, we need to ensure we return with the input, approval request and approval."""
from agent_framework import ChatMessage
result = await agent.run(query)
while len(result.user_input_requests) > 0:
new_inputs: list[Any] = [query]
for user_input_needed in result.user_input_requests:
print(
f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}"
f" with arguments: {user_input_needed.function_call.arguments}"
)
new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs)
return result
async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", thread: "AgentThread"):
"""Here we let the thread deal with the previous responses, and we just rerun with the approval."""
from agent_framework import ChatMessage
result = await agent.run(query, thread=thread, store=True)
while len(result.user_input_requests) > 0:
new_input: list[Any] = []
for user_input_needed in result.user_input_requests:
print(
f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}"
f" with arguments: {user_input_needed.function_call.arguments}"
)
user_approval = input("Approve function call? (y/n): ")
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread, store=True)
return result
async def handle_approvals_with_thread_streaming(query: str, agent: "AgentProtocol", thread: "AgentThread"):
"""Here we let the thread deal with the previous responses, and we just rerun with the approval."""
from agent_framework import ChatMessage
new_input: list[ChatMessage] = []
new_input_added = True
while new_input_added:
new_input_added = False
new_input.append(ChatMessage(role="user", text=query))
async for update in agent.run_stream(new_input, thread=thread, store=True):
if update.user_input_requests:
for user_input_needed in update.user_input_requests:
print(
f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}"
f" with arguments: {user_input_needed.function_call.arguments}"
)
user_approval = input("Approve function call? (y/n): ")
new_input.append(
ChatMessage(
role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")]
)
)
new_input_added = True
else:
yield update
async def run_hosted_mcp_without_thread_and_specific_approval() -> None:
"""Example showing Mcp Tools with approvals without using a thread."""
print("=== Mcp with approvals and without thread ===")
credential = AzureCliCredential()
# Tools are provided when creating the agent
# The agent can use these tools for any query during its lifetime
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(
credential=credential,
),
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
# we don't require approval for microsoft_docs_search tool calls
# but we do for any other tool
approval_mode={"never_require_approval": ["microsoft_docs_search"]},
),
) as agent:
# First query
query1 = "How to create an Azure storage account using az cli?"
print(f"User: {query1}")
result1 = await handle_approvals_without_thread(query1, agent)
print(f"{agent.name}: {result1}\n")
print("\n=======================================\n")
# Second query
query2 = "What is Microsoft Agent Framework?"
print(f"User: {query2}")
result2 = await handle_approvals_without_thread(query2, agent)
print(f"{agent.name}: {result2}\n")
async def run_hosted_mcp_without_approval() -> None:
"""Example showing Mcp Tools without approvals."""
print("=== Mcp without approvals ===")
credential = AzureCliCredential()
# Tools are provided when creating the agent
# The agent can use these tools for any query during its lifetime
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(
credential=credential,
),
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
# we don't require approval for any function calls
# this means we will not see the approval messages,
# it is fully handled by the service and a final response is returned.
approval_mode="never_require",
),
) as agent:
# First query
query1 = "How to create an Azure storage account using az cli?"
print(f"User: {query1}")
result1 = await handle_approvals_without_thread(query1, agent)
print(f"{agent.name}: {result1}\n")
print("\n=======================================\n")
# Second query
query2 = "What is Microsoft Agent Framework?"
print(f"User: {query2}")
result2 = await handle_approvals_without_thread(query2, agent)
print(f"{agent.name}: {result2}\n")
async def run_hosted_mcp_with_thread() -> None:
"""Example showing Mcp Tools with approvals using a thread."""
print("=== Mcp with approvals and with thread ===")
credential = AzureCliCredential()
# Tools are provided when creating the agent
# The agent can use these tools for any query during its lifetime
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(
credential=credential,
),
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
# we require approval for all function calls
approval_mode="always_require",
),
) as agent:
# First query
thread = agent.get_new_thread()
query1 = "How to create an Azure storage account using az cli?"
print(f"User: {query1}")
result1 = await handle_approvals_with_thread(query1, agent, thread)
print(f"{agent.name}: {result1}\n")
print("\n=======================================\n")
# Second query
query2 = "What is Microsoft Agent Framework?"
print(f"User: {query2}")
result2 = await handle_approvals_with_thread(query2, agent, thread)
print(f"{agent.name}: {result2}\n")
async def run_hosted_mcp_with_thread_streaming() -> None:
"""Example showing Mcp Tools with approvals using a thread."""
print("=== Mcp with approvals and with thread ===")
credential = AzureCliCredential()
# Tools are provided when creating the agent
# The agent can use these tools for any query during its lifetime
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(
credential=credential,
),
name="DocsAgent",
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
# we require approval for all function calls
approval_mode="always_require",
),
) as agent:
# First query
thread = agent.get_new_thread()
query1 = "How to create an Azure storage account using az cli?"
print(f"User: {query1}")
print(f"{agent.name}: ", end="")
async for update in handle_approvals_with_thread_streaming(query1, agent, thread):
print(update, end="")
print("\n")
print("\n=======================================\n")
# Second query
query2 = "What is Microsoft Agent Framework?"
print(f"User: {query2}")
print(f"{agent.name}: ", end="")
async for update in handle_approvals_with_thread_streaming(query2, agent, thread):
print(update, end="")
print("\n")
async def main() -> None:
print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n")
await run_hosted_mcp_without_approval()
await run_hosted_mcp_without_thread_and_specific_approval()
await run_hosted_mcp_with_thread()
await run_hosted_mcp_with_thread_streaming()
if __name__ == "__main__":
asyncio.run(main())
+3448 -3432
View File
File diff suppressed because it is too large Load Diff