mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1ee732ed | ||
|
|
a75590eb9b | ||
|
|
15d0bda8a2 | ||
|
|
f04f5ef297 | ||
|
|
21dceca482 | ||
|
|
6d890e46ed | ||
|
|
de2abdf573 | ||
|
|
f273ca7353 | ||
|
|
7e5de8f920 | ||
|
|
e92dcb3d5d |
@@ -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 />
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
+821
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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())
|
||||
+3
-3
@@ -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!")
|
||||
|
||||
+240
@@ -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())
|
||||
Generated
+3448
-3432
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user