mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f9536b1c | ||
|
|
ded32f3ff8 | ||
|
|
e2f0bc814e | ||
|
|
6cb2289a16 | ||
|
|
2aaca50217 | ||
|
|
f74bda5a83 | ||
|
|
23d6d91c8f | ||
|
|
d5e240b375 | ||
|
|
1ca43f9643 | ||
|
|
b98880df32 | ||
|
|
c8750cbe92 |
@@ -8,6 +8,10 @@ inputs:
|
||||
os:
|
||||
description: The operating system to set up
|
||||
required: true
|
||||
exclude-packages:
|
||||
description: Space-separated list of packages to exclude from uv sync
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -19,6 +23,20 @@ runs:
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ inputs.os }}-${{ inputs.python-version }}
|
||||
cache-dependency-glob: "**/uv.lock"
|
||||
- name: Exclude incompatible workspace packages
|
||||
if: ${{ inputs.exclude-packages != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
for pkg in ${{ inputs.exclude-packages }}; do
|
||||
for f in python/packages/*/pyproject.toml; do
|
||||
if grep -q "name = \"$pkg\"" "$f"; then
|
||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||
fi
|
||||
done
|
||||
done
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -67,6 +67,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
|
||||
@@ -34,12 +34,13 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.3.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.1" />
|
||||
<PackageVersion Include="Anthropic" Version="12.8.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.2" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
|
||||
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state
|
||||
/// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store
|
||||
/// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector
|
||||
/// store for relevant older messages and prepends them as a memory context message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only non-system messages are counted towards the session state limit and overflow mechanism. System messages are always retained in session state and are not included in the vector store.
|
||||
/// Function calls and function results are also dropped when truncation happens, both from in-memory state, and they are also not persisted to the vector store.
|
||||
/// </remarks>
|
||||
internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
{
|
||||
private readonly InMemoryChatHistoryProvider _chatHistoryProvider;
|
||||
private readonly ChatHistoryMemoryProvider _memoryProvider;
|
||||
private readonly TruncatingChatReducer _reducer;
|
||||
private readonly string _contextPrompt;
|
||||
private IReadOnlyList<string>? _stateKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param>
|
||||
/// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param>
|
||||
public BoundedChatHistoryProvider(
|
||||
int maxSessionMessages,
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer,
|
||||
string? contextPrompt = null)
|
||||
{
|
||||
if (maxSessionMessages < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative.");
|
||||
}
|
||||
|
||||
this._reducer = new TruncatingChatReducer(maxSessionMessages);
|
||||
this._chatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = this._reducer,
|
||||
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._memoryProvider = new ChatHistoryMemoryProvider(
|
||||
vectorStore,
|
||||
collectionName,
|
||||
vectorDimensions,
|
||||
stateInitializer,
|
||||
options: new ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
SearchInputMessageFilter = msgs => msgs,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._contextPrompt = contextPrompt
|
||||
?? "The following are memories from earlier in this conversation. Use them to inform your responses:";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= this._chatHistoryProvider.StateKeys.Concat(this._memoryProvider.StateKeys).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
|
||||
InvokingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages).
|
||||
var chatHistoryProviderInputContext = new InvokingContext(context.Agent, context.Session, []);
|
||||
var allMessages = await this._chatHistoryProvider.InvokingAsync(chatHistoryProviderInputContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Search the vector store for relevant older messages.
|
||||
var aiContext = new AIContext { Messages = context.RequestMessages.ToList() };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
context.Agent, context.Session, aiContext);
|
||||
|
||||
var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Extract only the messages added by the memory provider (stamped with AIContextProvider source type).
|
||||
var memoryMessages = result.Messages?
|
||||
.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider)
|
||||
.ToList();
|
||||
|
||||
if (memoryMessages is { Count: > 0 })
|
||||
{
|
||||
var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(memoryText))
|
||||
{
|
||||
var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}");
|
||||
return new[] { contextMessage }.Concat(allMessages);
|
||||
}
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(
|
||||
InvokedContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger)
|
||||
// will automatically truncate to the configured maximum and expose any removed messages.
|
||||
var innerContext = new InvokedContext(
|
||||
context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!);
|
||||
await this._chatHistoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Archive any messages that the reducer removed to the vector store.
|
||||
if (this._reducer.RemovedMessages is { Count: > 0 })
|
||||
{
|
||||
var overflowContext = new AIContextProvider.InvokedContext(
|
||||
context.Agent, context.Session, this._reducer.RemovedMessages, []);
|
||||
await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._memoryProvider.Dispose();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create a bounded chat history provider that keeps a configurable number of
|
||||
// recent messages in session state and automatically overflows older messages to a vector store.
|
||||
// When the agent is invoked, it searches the vector store for relevant older messages and
|
||||
// prepends them as a "memory" context message before the recent session history.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using OpenAI.Chat;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var credential = new DefaultAzureCredential();
|
||||
|
||||
// Create a vector store to store overflow chat messages.
|
||||
// For demonstration purposes, we are using an in-memory vector store.
|
||||
// Replace this with a persistent vector store implementation for production scenarios.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions()
|
||||
{
|
||||
EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetEmbeddingClient(embeddingDeploymentName)
|
||||
.AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state.
|
||||
// It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a
|
||||
// ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are
|
||||
// automatically archived to the vector store and recalled via semantic search.
|
||||
var boundedProvider = new BoundedChatHistoryProvider(
|
||||
maxSessionMessages: 4,
|
||||
vectorStore,
|
||||
collectionName: "chathistory-overflow",
|
||||
vectorDimensions: 3072,
|
||||
session => new ChatHistoryMemoryProvider.State(
|
||||
storageScope: new() { UserId = "UID1", SessionId = sessionId },
|
||||
searchScope: new() { UserId = "UID1" }));
|
||||
|
||||
// Create the agent with the bounded chat history provider.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." },
|
||||
Name = "Assistant",
|
||||
ChatHistoryProvider = boundedProvider,
|
||||
});
|
||||
|
||||
// Start a conversation. The first several exchanges will fill up the session state window.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("--- Filling the session window (4 messages max) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session));
|
||||
Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session));
|
||||
|
||||
// At this point the session state holds 4 messages (2 user + 2 assistant).
|
||||
// The next exchange will push the oldest messages into the vector store.
|
||||
Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the capital of France?", session));
|
||||
|
||||
// The oldest messages about favorite color have now been archived to the vector store.
|
||||
// Ask the agent something that requires recalling the overflowed information.
|
||||
Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is my favorite color?", session));
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bounded Chat History with Vector Store Overflow
|
||||
|
||||
This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps a bounded window of recent messages in session state and automatically overflows older messages to a vector store. When the agent is invoked, it searches the vector store for relevant older messages and prepends them as memory context.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property.
|
||||
- **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes:
|
||||
- `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer)
|
||||
- `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure OpenAI resource with:
|
||||
- A chat deployment (e.g., `gpt-4o-mini`)
|
||||
- An embedding deployment (e.g., `text-embedding-3-large`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` |
|
||||
| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
1. The agent starts a conversation with a bounded session window of 4 non-system, non-function messages (i.e., user/assistant turns). System messages are always preserved, and function call/result messages are truncated and not preserved.
|
||||
2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages.
|
||||
3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`.
|
||||
4. On subsequent invocations, the provider searches the vector store for relevant older messages and prepends them as memory context, allowing the agent to recall information from earlier in the conversation.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A truncating chat reducer that keeps the most recent messages up to a configured maximum,
|
||||
/// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/>
|
||||
/// so that a caller can archive them (e.g. to a vector store).
|
||||
/// </summary>
|
||||
internal sealed class TruncatingChatReducer : IChatReducer
|
||||
{
|
||||
private readonly int _maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The maximum number of non-system messages to retain.</param>
|
||||
public TruncatingChatReducer(int maxMessages)
|
||||
{
|
||||
this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = messages ?? throw new ArgumentNullException(nameof(messages));
|
||||
|
||||
ChatMessage? systemMessage = null;
|
||||
Queue<ChatMessage> retained = new(capacity: this._maxMessages);
|
||||
List<ChatMessage> removed = [];
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// Preserve the first system message outside the counting window.
|
||||
systemMessage ??= message;
|
||||
}
|
||||
else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
|
||||
{
|
||||
if (retained.Count >= this._maxMessages)
|
||||
{
|
||||
removed.Add(retained.Dequeue());
|
||||
}
|
||||
|
||||
retained.Enqueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
this.RemovedMessages = removed;
|
||||
|
||||
IEnumerable<ChatMessage> result = systemMessage is not null
|
||||
? new[] { systemMessage }.Concat(retained)
|
||||
: retained;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|
||||
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
|
||||
|
||||
@@ -20,6 +20,19 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="AIAgent"/> serves as the foundational class for implementing AI agents that can participate in conversations
|
||||
/// and process user requests. An agent instance may participate in multiple concurrent conversations, and each conversation
|
||||
/// may involve multiple agents working together.
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> An <see cref="AIAgent"/> orchestrates data flow across trust boundaries —
|
||||
/// messages are sent to external AI services, context providers, chat history stores, and function tools. Agent Framework
|
||||
/// passes messages through as-is without validation or sanitization. Developers must be aware that:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>User-supplied messages may contain prompt injection attempts designed to manipulate LLM behavior.</description></item>
|
||||
/// <item><description>LLM responses should be treated as untrusted output — they may contain hallucinations, malicious payloads (e.g., scripts, SQL),
|
||||
/// or content influenced by indirect prompt injection. Always validate and sanitize LLM output before rendering in HTML, executing as code,
|
||||
/// or using in database queries.</description></item>
|
||||
/// <item><description>Messages with different roles carry different trust levels: <c>system</c> messages have the highest trust and must be developer-controlled;
|
||||
/// <c>user</c>, <c>assistant</c>, and <c>tool</c> messages should be treated as untrusted.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract partial class AIAgent
|
||||
@@ -165,6 +178,11 @@ public abstract partial class AIAgent
|
||||
/// This method enables saving conversation sessions to persistent storage,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances. Use <see cref="DeserializeSessionAsync"/> to restore the session.
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Serialized sessions may contain conversation content, session identifiers,
|
||||
/// and other potentially sensitive data including PII. Ensure that serialized session data is stored securely with
|
||||
/// appropriate access controls and encryption at rest.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<JsonElement> SerializeSessionAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.SerializeSessionCoreAsync(session, jsonSerializerOptions, cancellationToken);
|
||||
@@ -194,6 +212,12 @@ public abstract partial class AIAgent
|
||||
/// This method enables restoration of conversation sessions from previously saved state,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances.
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Restoring a session from an untrusted source is equivalent to accepting untrusted input.
|
||||
/// Serialized sessions may contain conversation content, session identifiers, and potentially sensitive data. A compromised
|
||||
/// storage backend could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.
|
||||
/// Treat serialized session data as sensitive and ensure it is stored and transmitted securely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.DeserializeSessionCoreAsync(serializedState, jsonSerializerOptions, cancellationToken);
|
||||
@@ -301,6 +325,11 @@ public abstract partial class AIAgent
|
||||
/// The messages are processed in the order provided and become part of the conversation history.
|
||||
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
|
||||
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
|
||||
/// System-role messages must be developer-controlled and should never contain end-user input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<AgentResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
@@ -426,6 +455,11 @@ public abstract partial class AIAgent
|
||||
/// Each <see cref="AgentResponseUpdate"/> represents a portion of the complete response, allowing consumers
|
||||
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
|
||||
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
|
||||
/// System-role messages must be developer-controlled and should never contain end-user input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
|
||||
@@ -28,6 +28,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="InvokingAsync"/> to provide context, and optionally called at the end of invocation via
|
||||
/// <see cref="InvokedAsync"/> to process results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Context providers may inject messages with any role, including <c>system</c>, which
|
||||
/// has the highest trust level and directly shapes LLM behavior. Developers must ensure that all providers attached to an agent
|
||||
/// are trusted. Agent Framework does not validate or filter the data returned by providers — it is accepted as-is and merged into
|
||||
/// the request context. If a provider retrieves data from an external source (e.g., a vector database or memory service), be aware
|
||||
/// that a compromised data source could introduce adversarial content designed to manipulate LLM behavior via indirect prompt injection.
|
||||
/// Implementers should validate and sanitize data retrieved from external sources before returning it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AIContextProvider
|
||||
{
|
||||
@@ -96,6 +104,11 @@ public abstract class AIContextProvider
|
||||
/// <item><description>Injecting contextual messages from conversation history</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Data retrieved from external sources (e.g., vector databases, memory services, or
|
||||
/// knowledge bases) may contain adversarial content designed to influence LLM behavior via indirect prompt injection.
|
||||
/// Implementers should validate data integrity and consider the trustworthiness of the data source.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
|
||||
@@ -195,6 +208,11 @@ public abstract class AIContextProvider
|
||||
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional context to be merged with the input,
|
||||
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full merged <see cref="AIContext"/> for the invocation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Any messages, tools, or instructions returned by this method will be merged into the
|
||||
/// AI request context. If data is retrieved from external or untrusted sources, implementers should validate and sanitize it
|
||||
/// to prevent indirect prompt injection attacks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
@@ -299,6 +317,10 @@ public abstract class AIContextProvider
|
||||
/// <para>
|
||||
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages being processed/stored may contain PII and sensitive conversation content.
|
||||
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected virtual ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
@@ -42,6 +42,15 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method
|
||||
/// can be used to deserialize the session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Serialized sessions may contain conversation content, session identifiers,
|
||||
/// and other potentially sensitive data including PII. Developers should:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Treat serialized session data as sensitive and store it securely with appropriate access controls and encryption at rest.</description></item>
|
||||
/// <item><description>Treat restoring a session from an untrusted source as equivalent to accepting untrusted input. A compromised storage backend
|
||||
/// could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="AIAgent"/>
|
||||
/// <seealso cref="AIAgent.CreateSessionAsync(System.Threading.CancellationToken)"/>
|
||||
@@ -67,6 +76,11 @@ public abstract class AgentSession
|
||||
/// <summary>
|
||||
/// Gets any arbitrary state associated with this session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Data stored in the <see cref="StateBag"/> will be included when the session is serialized.
|
||||
/// Avoid storing secrets, credentials, or highly sensitive data in the state bag without appropriate encryption,
|
||||
/// as this data may be persisted to external storage.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("stateBag")]
|
||||
public AgentSessionStateBag StateBag { get; protected set; } = new();
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ namespace Microsoft.Agents.AI;
|
||||
/// A <see cref="ChatHistoryProvider"/> is only relevant for scenarios where the underlying AI service that the agent is using
|
||||
/// does not use in-service chat history storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Agent Framework does not validate or filter the messages returned by the provider
|
||||
/// during load — they are accepted as-is and treated identically to user-supplied messages. Implementers must ensure that only
|
||||
/// trusted data is returned. If the underlying storage is compromised, adversarial content could influence LLM behavior via
|
||||
/// indirect prompt injection — for example, injected messages could alter the conversation context or impersonate different roles.
|
||||
/// Messages stored in chat history may contain PII and sensitive conversation content; implementers should consider encryption
|
||||
/// at rest and appropriate access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class ChatHistoryProvider
|
||||
{
|
||||
@@ -159,6 +167,11 @@ public abstract class ChatHistoryProvider
|
||||
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
|
||||
/// The oldest messages appear first in the collection, followed by more recent messages.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages loaded from storage should be treated with the same caution as user-supplied
|
||||
/// messages. A compromised storage backend could alter message roles to escalate trust (e.g., changing <c>user</c> messages to
|
||||
/// <c>system</c> messages) or inject adversarial content that influences LLM behavior.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
@@ -273,6 +286,10 @@ public abstract class ChatHistoryProvider
|
||||
/// <para>
|
||||
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> Messages being stored may contain PII and sensitive conversation content.
|
||||
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
@@ -17,6 +17,24 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="ChatHistoryProvider"/> abstract class.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>PII and sensitive data:</strong> Chat history stored in Cosmos DB may contain PII, sensitive conversation
|
||||
/// content, and system instructions. Ensure the Cosmos DB account is configured with appropriate access controls, encryption at rest,
|
||||
/// and network security (e.g., private endpoints, virtual network rules). The <see cref="MessageTtlSeconds"/> property can be used to
|
||||
/// automatically expire messages and limit data retention.</description></item>
|
||||
/// <item><description><strong>Compromised store risks:</strong> Agent Framework does not validate or filter messages loaded from the
|
||||
/// store — they are accepted as-is. If the Cosmos DB store is compromised, adversarial content could be injected into the conversation
|
||||
/// context, potentially influencing LLM behavior via indirect prompt injection. Altered message roles (e.g., changing <c>user</c> to
|
||||
/// <c>system</c>) could escalate trust levels.</description></item>
|
||||
/// <item><description><strong>Authentication:</strong> Agent Framework does not manage authentication or encryption for the Cosmos DB
|
||||
/// connection — these are the responsibility of the <see cref="CosmosClient"/> configuration. Use managed identity
|
||||
/// or token-based authentication where possible, and avoid embedding connection strings with keys in source code.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
|
||||
@@ -13,16 +13,38 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mem0;
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names - Microsoft.Extensions.Logging.LogLevel.Trace doesn't get found in net472 when removing the namespace.
|
||||
/// <summary>
|
||||
/// Provides a Mem0 backed <see cref="MessageAIContextProvider"/> that persists conversation messages as memories
|
||||
/// and retrieves related memories to augment the agent invocation context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The provider stores user, assistant and system messages as Mem0 memories and retrieves relevant memories
|
||||
/// for new invocations using a semantic search endpoint. Retrieved memories are injected as user messages
|
||||
/// to the model, prefixed by a configurable context prompt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>External service trust:</strong> This provider communicates with an external Mem0 service over HTTP.
|
||||
/// Agent Framework does not manage authentication, encryption, or connection details for this service — these are the responsibility
|
||||
/// of the <see cref="HttpClient"/> configuration. Ensure the HTTP client is configured with appropriate authentication
|
||||
/// and uses HTTPS to protect data in transit.</description></item>
|
||||
/// <item><description><strong>PII and sensitive data:</strong> Conversation messages (including user inputs, LLM responses, and system
|
||||
/// instructions) are sent to the external Mem0 service for storage. These messages may contain PII or sensitive information.
|
||||
/// Ensure the Mem0 service is configured with appropriate data retention policies and access controls.</description></item>
|
||||
/// <item><description><strong>Indirect prompt injection:</strong> Memories retrieved from the Mem0 service are injected into the LLM
|
||||
/// context as user messages. If the memory store is compromised, adversarial content could influence LLM behavior. The data
|
||||
/// returned from the service is accepted as-is without validation or sanitization.</description></item>
|
||||
/// <item><description><strong>Trace logging:</strong> When <see cref="Microsoft.Extensions.Logging.LogLevel.Trace"/> is enabled,
|
||||
/// full memory content (including search queries and results) may be logged. This data may contain PII and should not be enabled
|
||||
/// in production environments.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Mem0Provider : MessageAIContextProvider
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
{
|
||||
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
|
||||
|
||||
|
||||
@@ -17,6 +17,25 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AIAgent"/> that delegates to an <see cref="IChatClient"/> implementation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> The <see cref="ChatClientAgent"/> orchestrates data flow across trust boundaries.
|
||||
/// The underlying AI service is an external endpoint and LLM responses should be treated as untrusted output. Developers should be aware of:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>Hallucination:</strong> LLMs may generate plausible-sounding but factually incorrect information.
|
||||
/// Do not treat LLM output as authoritative without verification.</description></item>
|
||||
/// <item><description><strong>Indirect prompt injection:</strong> Data retrieved by tools, AI context providers, or chat history providers may
|
||||
/// contain adversarial content designed to influence LLM behavior or exfiltrate data through tool calls.</description></item>
|
||||
/// <item><description><strong>Malicious payloads:</strong> LLM output may contain content that is harmful if rendered or executed without
|
||||
/// sanitization — for example, HTML/JavaScript for cross-site scripting, SQL for injection, or shell commands.</description></item>
|
||||
/// <item><description><strong>Tool invocation:</strong> By default, all tools provided to the agent are invoked without user approval.
|
||||
/// The AI selects which functions to call and with what arguments. Function arguments should be treated as untrusted input.
|
||||
/// Developers should require explicit approval for tools with side effects, data sensitivity, or irreversibility.</description></item>
|
||||
/// </list>
|
||||
/// Developers should validate and sanitize LLM output before rendering it in HTML, executing it as code, using it in database queries,
|
||||
/// or passing it to any security-sensitive context. Apply defense-in-depth by combining tool approval requirements with output validation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
private readonly ChatClientAgentOptions? _agentOptions;
|
||||
@@ -44,6 +63,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// Optional collection of tools that the agent can invoke during conversations.
|
||||
/// These tools augment any tools that may be provided to the agent via <see cref="ChatOptions.Tools"/> when
|
||||
/// the agent is run.
|
||||
/// By default, all provided tools are invoked without user approval. The AI selects which functions to call and chooses
|
||||
/// the arguments — these arguments should be treated as untrusted input. Developers should require explicit approval
|
||||
/// for tools that have side effects, access sensitive data, or perform irreversible operations.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names - Microsoft.Extensions.Logging.LogLevel.Trace doesn't get found in net472 when removing the namespace.
|
||||
/// <summary>
|
||||
/// A context provider that stores all chat history in a vector store and is able to
|
||||
/// retrieve related chat history later to augment the current conversation.
|
||||
@@ -33,8 +34,25 @@ namespace Microsoft.Agents.AI;
|
||||
/// exposes a function tool that the model can invoke to retrieve relevant memories on demand instead of
|
||||
/// injecting them automatically on each invocation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><strong>Indirect prompt injection:</strong> Messages retrieved from the vector store via semantic search
|
||||
/// are injected into the LLM context. If the vector store is compromised, adversarial content could influence LLM behavior.
|
||||
/// The data returned from the store is accepted as-is without validation or sanitization.</description></item>
|
||||
/// <item><description><strong>PII and sensitive data:</strong> Conversation messages (including user inputs and LLM responses)
|
||||
/// are stored as vectors in the underlying store. These messages may contain PII or sensitive information. Ensure the vector
|
||||
/// store is configured with appropriate access controls and encryption at rest.</description></item>
|
||||
/// <item><description><strong>On-demand search tool:</strong> When using <see cref="ChatHistoryMemoryProviderOptions.SearchBehavior.OnDemandFunctionCalling"/>,
|
||||
/// the AI model controls when and what to search for. The search query is AI-generated and should be treated as untrusted input
|
||||
/// by the vector store implementation.</description></item>
|
||||
/// <item><description><strong>Trace logging:</strong> When <see cref="Microsoft.Extensions.Logging.LogLevel.Trace"/> is enabled,
|
||||
/// full search queries and results may be logged. This data may contain PII.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDisposable
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
{
|
||||
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
|
||||
private const int DefaultMaxResults = 3;
|
||||
|
||||
@@ -70,6 +70,12 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
/// and outputs, such as message content, function call arguments, and function call results.
|
||||
/// The default value can be overridden by setting the <c>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT</c>
|
||||
/// environment variable to "true". Explicitly setting this property will override the environment variable.
|
||||
/// <para>
|
||||
/// <strong>Security consideration:</strong> When sensitive data capture is enabled, the full text of chat messages —
|
||||
/// including user inputs, LLM responses, function call arguments, and function results — is emitted as telemetry.
|
||||
/// This data may contain PII or other sensitive information. Ensure that your telemetry pipeline is configured
|
||||
/// with appropriate access controls and data retention policies.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool EnableSensitiveData
|
||||
{
|
||||
|
||||
@@ -31,6 +31,18 @@ namespace Microsoft.Agents.AI;
|
||||
/// to the current request messages when forming the search input. This can improve search relevance by providing
|
||||
/// multi-turn context to the retrieval layer without permanently altering the conversation history.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> Search results retrieved from external sources are injected into the LLM context and may
|
||||
/// contain adversarial content designed to manipulate LLM behavior via indirect prompt injection. Developers should be aware that:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The search query may be constructed from user input or LLM-generated content, both of which are untrusted.
|
||||
/// Implementers of the search delegate should validate search inputs and apply appropriate access controls to search results.</description></item>
|
||||
/// <item><description>Retrieved documents are formatted and injected as messages in the AI request context. If the external data source
|
||||
/// is compromised, adversarial content could influence the LLM's responses.</description></item>
|
||||
/// <item><description>When using <see cref="TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling"/>, the AI model controls
|
||||
/// when and what to search for — the search query text is AI-generated and should be treated as untrusted input by the search implementation.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TextSearchProvider : MessageAIContextProvider
|
||||
{
|
||||
|
||||
@@ -20,6 +20,13 @@ When making changes to a package, check if the following need updates:
|
||||
- The package's `AGENTS.md` file (adding/removing/renaming public APIs, architecture changes, import path changes)
|
||||
- The agent skills in `.github/skills/` if conventions, commands, or workflows change
|
||||
|
||||
## Pull Request Description Guidance
|
||||
|
||||
When preparing a PR description:
|
||||
- Follow the repository PR template at `.github/pull_request_template.md` and keep its structure/headings.
|
||||
- Describe the net change relative to `main` (this is implied; do not call it out explicitly as "vs main").
|
||||
- Do not add ad-hoc validation sections (for example, "Validation" or "Tests run"); CI/CD and the template checklist cover validation status.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Run `uv run poe` from the `python/` directory to see available commands. See [DEV_SETUP.md](DEV_SETUP.md) for detailed usage.
|
||||
|
||||
@@ -37,9 +37,8 @@ from agent_framework.openai._responses_client import RawOpenAIResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
ApproximateLocation,
|
||||
CodeInterpreterContainerAuto,
|
||||
AutoCodeInterpreterToolParam,
|
||||
CodeInterpreterTool,
|
||||
FoundryFeaturesOptInKeys,
|
||||
ImageGenTool,
|
||||
MCPTool,
|
||||
PromptAgentDefinition,
|
||||
@@ -66,7 +65,6 @@ if sys.version_info >= (3, 11):
|
||||
else:
|
||||
from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
|
||||
logger = logging.getLogger("agent_framework.azure")
|
||||
|
||||
|
||||
@@ -79,9 +77,6 @@ class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False):
|
||||
reasoning: Reasoning # type: ignore[misc]
|
||||
"""Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning)."""
|
||||
|
||||
foundry_features: FoundryFeaturesOptInKeys | str
|
||||
"""Optional Foundry preview feature opt-in for agent version creation."""
|
||||
|
||||
|
||||
AzureAIClientOptionsT = TypeVar(
|
||||
"AzureAIClientOptionsT",
|
||||
@@ -123,6 +118,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -148,6 +144,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
@@ -208,11 +205,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
# Use provided credential
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when project_client is not provided.")
|
||||
project_client = AIProjectClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
should_close_client = True
|
||||
|
||||
# Initialize parent
|
||||
@@ -413,8 +413,6 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
"definition": PromptAgentDefinition(**args),
|
||||
"description": self.agent_description,
|
||||
}
|
||||
if foundry_features := run_options.get("foundry_features"):
|
||||
create_version_kwargs["foundry_features"] = foundry_features
|
||||
|
||||
created_agent = await self.project_client.agents.create_version(**create_version_kwargs)
|
||||
|
||||
@@ -513,7 +511,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
"temperature": ("temperature",),
|
||||
"top_p": ("top_p",),
|
||||
"reasoning": ("reasoning",),
|
||||
"foundry_features": ("foundry_features",),
|
||||
"allow_preview": ("allow_preview",),
|
||||
}
|
||||
|
||||
for run_keys in agent_level_option_to_run_keys.values():
|
||||
@@ -939,7 +937,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
if file_ids is None and isinstance(container, dict):
|
||||
file_ids = container.get("file_ids")
|
||||
resolved = resolve_file_ids(file_ids)
|
||||
tool_container = CodeInterpreterContainerAuto(file_ids=resolved)
|
||||
tool_container = AutoCodeInterpreterToolParam(file_ids=resolved)
|
||||
return CodeInterpreterTool(container=tool_container, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@@ -1244,6 +1242,7 @@ class AzureAIClient(
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -1268,6 +1267,7 @@ class AzureAIClient(
|
||||
or AsyncTokenCredential.
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``
|
||||
middleware: Optional sequence of chat middlewares to include.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
@@ -1318,6 +1318,7 @@ class AzureAIClient(
|
||||
model_deployment_name=model_deployment_name,
|
||||
credential=credential,
|
||||
use_latest_version=use_latest_version,
|
||||
allow_preview=allow_preview,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
env_file_path=env_file_path,
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent_framework._sessions import AgentSession, BaseContextProvider, Session
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from openai.types.responses import ResponseInputItemParam
|
||||
|
||||
from ._shared import AzureAISettings
|
||||
|
||||
@@ -58,6 +59,7 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
project_client: AIProjectClient | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
memory_store_name: str,
|
||||
scope: str | None = None,
|
||||
context_prompt: str | None = None,
|
||||
@@ -74,6 +76,7 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required when project_client is not provided.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
memory_store_name: The name of the memory store to use.
|
||||
scope: The namespace that logically groups and isolates memories (e.g., user ID).
|
||||
If None, `session_id` will be used.
|
||||
@@ -100,11 +103,14 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
)
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when project_client is not provided.")
|
||||
project_client = AIProjectClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
|
||||
if not memory_store_name:
|
||||
raise ValueError("memory_store_name is required")
|
||||
@@ -169,8 +175,8 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
return
|
||||
|
||||
# Convert input messages to memory search item format
|
||||
items = [
|
||||
{"type": "text", "text": msg.text}
|
||||
items: list[ResponseInputItemParam] = [
|
||||
{"type": "message", "role": "user", "content": msg.text}
|
||||
for msg in context.input_messages
|
||||
if msg and msg.text and msg.text.strip()
|
||||
]
|
||||
@@ -224,7 +230,7 @@ class FoundryMemoryProvider(BaseContextProvider):
|
||||
messages_to_store.extend(context.response.messages)
|
||||
|
||||
# Filter and convert messages to memory update item format
|
||||
items: list[dict[str, str]] = []
|
||||
items: list[ResponseInputItemParam] = []
|
||||
for message in messages_to_store:
|
||||
if message.role in {"user", "assistant", "system"} and message.text and message.text.strip():
|
||||
if message.role == "user":
|
||||
|
||||
@@ -102,6 +102,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
project_endpoint: str | None = None,
|
||||
model: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
@@ -117,6 +118,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required when project_client is not provided.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
|
||||
@@ -146,11 +148,14 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when project_client is not provided.")
|
||||
|
||||
project_client = AIProjectClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
self._should_close_client = True
|
||||
|
||||
self._project_client = project_client
|
||||
@@ -199,7 +204,6 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
response_format = opts.get("response_format")
|
||||
rai_config = opts.get("rai_config")
|
||||
reasoning = opts.get("reasoning")
|
||||
foundry_features = opts.get("foundry_features")
|
||||
|
||||
args: dict[str, Any] = {"model": resolved_model}
|
||||
|
||||
@@ -246,8 +250,6 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
"definition": PromptAgentDefinition(**args),
|
||||
"description": description,
|
||||
}
|
||||
if foundry_features:
|
||||
create_version_kwargs["foundry_features"] = foundry_features
|
||||
|
||||
created_agent = await self._project_client.agents.create_version(**create_version_kwargs)
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ from azure.ai.agents.models import (
|
||||
from azure.ai.projects.models import (
|
||||
CodeInterpreterTool,
|
||||
MCPTool,
|
||||
TextResponseFormatConfigurationResponseFormatJsonObject,
|
||||
TextResponseFormatConfigurationResponseFormatText,
|
||||
TextResponseFormatJsonObject,
|
||||
TextResponseFormatJsonSchema,
|
||||
TextResponseFormatText,
|
||||
Tool,
|
||||
WebSearchPreviewTool,
|
||||
)
|
||||
@@ -479,11 +479,7 @@ def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool:
|
||||
|
||||
def create_text_format_config(
|
||||
response_format: type[BaseModel] | Mapping[str, Any],
|
||||
) -> (
|
||||
TextResponseFormatJsonSchema
|
||||
| TextResponseFormatConfigurationResponseFormatJsonObject
|
||||
| TextResponseFormatConfigurationResponseFormatText
|
||||
):
|
||||
) -> TextResponseFormatJsonSchema | TextResponseFormatJsonObject | TextResponseFormatText:
|
||||
"""Convert response_format into Azure text format configuration."""
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
schema = response_format.model_json_schema()
|
||||
@@ -513,9 +509,9 @@ def create_text_format_config(
|
||||
config_kwargs["description"] = format_config["description"]
|
||||
return TextResponseFormatJsonSchema(**config_kwargs)
|
||||
if format_type == "json_object":
|
||||
return TextResponseFormatConfigurationResponseFormatJsonObject()
|
||||
return TextResponseFormatJsonObject()
|
||||
if format_type == "text":
|
||||
return TextResponseFormatConfigurationResponseFormatText()
|
||||
return TextResponseFormatText()
|
||||
|
||||
raise IntegrationInvalidRequestException("response_format must be a Pydantic model or mapping.")
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework.openai._responses_client import RawOpenAIResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
ApproximateLocation,
|
||||
CodeInterpreterContainerAuto,
|
||||
AutoCodeInterpreterToolParam,
|
||||
CodeInterpreterTool,
|
||||
FileSearchTool,
|
||||
ImageGenTool,
|
||||
@@ -1296,7 +1296,7 @@ def test_from_azure_ai_tools_mcp() -> None:
|
||||
|
||||
def test_from_azure_ai_tools_code_interpreter() -> None:
|
||||
"""Test from_azure_ai_tools with Code Interpreter tool."""
|
||||
ci_tool = CodeInterpreterTool(container=CodeInterpreterContainerAuto(file_ids=["file-1"]))
|
||||
ci_tool = CodeInterpreterTool(container=AutoCodeInterpreterToolParam(file_ids=["file-1"]))
|
||||
parsed_tools = from_azure_ai_tools([ci_tool])
|
||||
assert len(parsed_tools) == 1
|
||||
assert parsed_tools[0]["type"] == "code_interpreter"
|
||||
|
||||
@@ -86,6 +86,7 @@ class TestInit:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential, # type: ignore[arg-type]
|
||||
allow_preview=True,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
@@ -93,6 +94,7 @@ class TestInit:
|
||||
mock_ai_project_client.assert_called_once_with(
|
||||
endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential,
|
||||
allow_preview=True,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,9 +112,7 @@ class SkillResource:
|
||||
self._accepts_kwargs: bool = False
|
||||
if function is not None:
|
||||
sig = inspect.signature(function)
|
||||
self._accepts_kwargs = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
||||
)
|
||||
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
||||
|
||||
|
||||
class Skill:
|
||||
|
||||
@@ -1458,7 +1458,7 @@ def _update_conversation_id(
|
||||
if conversation_id is None:
|
||||
return
|
||||
if "chat_options" in kwargs:
|
||||
kwargs["chat_options"].conversation_id = conversation_id
|
||||
kwargs["chat_options"]["conversation_id"] = conversation_id
|
||||
else:
|
||||
kwargs["conversation_id"] = conversation_id
|
||||
|
||||
|
||||
@@ -2776,6 +2776,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
except StopAsyncIteration:
|
||||
self._consumed = True
|
||||
await self._run_cleanup_hooks()
|
||||
await self.get_final_response()
|
||||
raise
|
||||
except Exception:
|
||||
await self._run_cleanup_hooks()
|
||||
@@ -2825,34 +2826,38 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
await self._get_stream()
|
||||
if self._inner_stream is None:
|
||||
raise RuntimeError("Inner stream not available")
|
||||
if not self._finalized:
|
||||
if not self._finalized and not self._consumed:
|
||||
# Consume outer stream (which delegates to inner) if not already consumed
|
||||
if not self._consumed:
|
||||
async for _ in self:
|
||||
pass
|
||||
async for _ in self:
|
||||
pass
|
||||
|
||||
# First, finalize the inner stream and run its result hooks
|
||||
# Re-check: __anext__ auto-finalization may have already finalized this stream
|
||||
if not self._finalized:
|
||||
# This ensures inner post-processing (e.g., context provider notifications) runs
|
||||
inner_stream = self._inner_stream
|
||||
inner_result: Any
|
||||
if inner_stream._finalizer is not None:
|
||||
inner_finalizer = inner_stream._finalizer
|
||||
inner_result = inner_finalizer(inner_stream._updates)
|
||||
if isawaitable(inner_result):
|
||||
inner_result = await inner_result
|
||||
else:
|
||||
inner_result = list(inner_stream._updates)
|
||||
# Skip if inner stream was already finalized (e.g., via auto-finalization on iteration)
|
||||
if not self._inner_stream._finalized:
|
||||
inner_stream = self._inner_stream
|
||||
inner_result: Any
|
||||
if inner_stream._finalizer is not None:
|
||||
inner_finalizer = inner_stream._finalizer
|
||||
inner_result = inner_finalizer(inner_stream._updates)
|
||||
if isawaitable(inner_result):
|
||||
inner_result = await inner_result
|
||||
else:
|
||||
inner_result = list(inner_stream._updates)
|
||||
|
||||
# Run inner stream's result hooks
|
||||
inner_hooks = cast(list[Callable[[Any], Any | Awaitable[Any] | None]], inner_stream._result_hooks)
|
||||
for hook in inner_hooks:
|
||||
hooked_result = hook(inner_result)
|
||||
if isawaitable(hooked_result):
|
||||
hooked_result = await hooked_result
|
||||
if hooked_result is not None:
|
||||
inner_result = hooked_result
|
||||
inner_stream._final_result = inner_result
|
||||
inner_stream._finalized = True
|
||||
# Run inner stream's result hooks
|
||||
inner_hooks = cast(list[Callable[[Any], Any | Awaitable[Any] | None]], inner_stream._result_hooks)
|
||||
for hook in inner_hooks:
|
||||
hooked_result = hook(inner_result)
|
||||
if isawaitable(hooked_result):
|
||||
hooked_result = await hooked_result
|
||||
if hooked_result is not None:
|
||||
inner_result = hooked_result
|
||||
inner_stream._final_result = inner_result
|
||||
inner_stream._finalized = True
|
||||
else:
|
||||
inner_result = self._inner_stream._final_result
|
||||
|
||||
# Now finalize the outer stream with its own finalizer
|
||||
# If outer has no finalizer, use inner's result (preserves from_awaitable behavior)
|
||||
@@ -2877,12 +2882,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
self._finalized = True
|
||||
return self._final_result # type: ignore[return-value]
|
||||
|
||||
if not self._finalized:
|
||||
if not self._consumed:
|
||||
async for _ in self:
|
||||
pass
|
||||
if not self._finalized and not self._consumed:
|
||||
async for _ in self:
|
||||
pass
|
||||
|
||||
# Use finalizer if configured, otherwise return collected updates
|
||||
# Re-check: __anext__ auto-finalization may have already finalized this stream
|
||||
if not self._finalized:
|
||||
result: Any
|
||||
if self._finalizer is not None:
|
||||
result = self._finalizer(self._updates)
|
||||
|
||||
@@ -73,6 +73,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
project_client: Any | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
@@ -120,6 +121,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL.
|
||||
When provided with ``credential``, an ``AIProjectClient`` will be created
|
||||
and used to obtain the OpenAI client. Requires the ``azure-ai-projects`` package.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
env_file_path: Use the environment settings file as a fallback to using env vars.
|
||||
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
|
||||
instruction_role: The role to use for 'instruction' messages, for example, summarization
|
||||
@@ -189,6 +191,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
project_client=project_client,
|
||||
project_endpoint=project_endpoint,
|
||||
credential=credential,
|
||||
allow_preview=allow_preview,
|
||||
)
|
||||
|
||||
azure_openai_settings = load_settings(
|
||||
@@ -246,21 +249,9 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
project_client: AIProjectClient | None,
|
||||
project_endpoint: str | None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None,
|
||||
allow_preview: bool | None = None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Create an AsyncOpenAI client from an Azure AI Foundry project.
|
||||
|
||||
Args:
|
||||
project_client: An existing AIProjectClient to use.
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL.
|
||||
credential: Azure credential for authentication.
|
||||
|
||||
Returns:
|
||||
An AsyncAzureOpenAI client obtained from the project client.
|
||||
|
||||
Raises:
|
||||
ValueError: If required parameters are missing or
|
||||
the azure-ai-projects package is not installed.
|
||||
"""
|
||||
"""Create an AsyncOpenAI client from an Azure AI Foundry project."""
|
||||
if project_client is not None:
|
||||
return project_client.get_openai_client()
|
||||
|
||||
@@ -268,11 +259,14 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
raise ValueError("Azure AI project endpoint is required when project_client is not provided.")
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
|
||||
project_client = AIProjectClient(
|
||||
endpoint=project_endpoint,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": project_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
return project_client.get_openai_client()
|
||||
|
||||
@override
|
||||
|
||||
@@ -327,7 +327,9 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
|
||||
|
||||
# Start with a copy of options
|
||||
run_options = {k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools"}}
|
||||
run_options = {
|
||||
k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools", "conversation_id"}
|
||||
}
|
||||
|
||||
# messages
|
||||
if messages and "messages" not in run_options:
|
||||
|
||||
@@ -34,7 +34,7 @@ dependencies = [
|
||||
# connectors and functions
|
||||
"openai>=1.99.0",
|
||||
"azure-identity>=1,<2",
|
||||
"azure-ai-projects == 2.0.0b4",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
"mcp[ws]>=1.24.0,<2",
|
||||
"packaging>=24.1",
|
||||
]
|
||||
@@ -55,7 +55,7 @@ all = [
|
||||
"agent-framework-devui",
|
||||
"agent-framework-durabletask",
|
||||
"agent-framework-foundry-local",
|
||||
"agent-framework-github-copilot",
|
||||
"agent-framework-github-copilot; python_version >= '3.11'",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
|
||||
@@ -626,6 +626,73 @@ async def test_streaming_with_none_delta(
|
||||
assert any(msg.contents for msg in results)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_with_conversation_id(
|
||||
mock_create: AsyncMock,
|
||||
azure_openai_unit_test_env: dict[str, str],
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
"""Test that conversation_id is excluded from the completions create call."""
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(text="hello world", role="user"))
|
||||
|
||||
azure_chat_client = AzureOpenAIChatClient()
|
||||
await azure_chat_client.get_response(
|
||||
messages=chat_history,
|
||||
options={"conversation_id": "12345"},
|
||||
)
|
||||
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert "conversation_id" not in call_kwargs
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_streaming_with_conversation_id(
|
||||
mock_create: AsyncMock,
|
||||
azure_openai_unit_test_env: dict[str, str],
|
||||
chat_history: list[Message],
|
||||
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
|
||||
) -> None:
|
||||
"""Test that conversation_id is excluded from the streaming completions create call."""
|
||||
mock_create.return_value = mock_streaming_chat_completion_response
|
||||
chat_history.append(Message(text="hello world", role="user"))
|
||||
|
||||
azure_chat_client = AzureOpenAIChatClient()
|
||||
async for _ in azure_chat_client.get_response(
|
||||
messages=chat_history,
|
||||
options={"conversation_id": "12345"},
|
||||
stream=True,
|
||||
):
|
||||
pass
|
||||
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert "conversation_id" not in call_kwargs
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_agent_with_service_session_id(
|
||||
mock_create: AsyncMock,
|
||||
azure_openai_unit_test_env: dict[str, str],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
"""Test that agent.run() with a session containing service_session_id works correctly."""
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
|
||||
azure_chat_client = AzureOpenAIChatClient()
|
||||
agent = azure_chat_client.as_agent(
|
||||
name="TestAgent",
|
||||
instructions="You are a helpful assistant.",
|
||||
)
|
||||
|
||||
session = agent.get_session(service_session_id="12345")
|
||||
response = await agent.run("hello", session=session)
|
||||
|
||||
assert response is not None
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
assert "conversation_id" not in call_kwargs
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_story_text() -> str:
|
||||
"""Returns a story about Emily and David."""
|
||||
|
||||
@@ -357,6 +357,40 @@ async def test_chat_client_agent_streaming_session_id_set_without_get_final_resp
|
||||
assert session.service_session_id == "resp_123"
|
||||
|
||||
|
||||
async def test_chat_client_agent_streaming_session_history_saved_without_get_final_response(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Test that session history is saved after streaming iteration without get_final_response().
|
||||
|
||||
Auto-finalization on iteration completion should trigger after_run providers,
|
||||
persisting conversation history to the session.
|
||||
"""
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
chat_client_base.streaming_responses = [
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_text("Hello Alice!")],
|
||||
role="assistant",
|
||||
response_id="resp_1",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
]
|
||||
|
||||
agent = Agent(client=chat_client_base)
|
||||
session = agent.create_session()
|
||||
|
||||
# Only iterate — do NOT call get_final_response()
|
||||
async for _ in agent.run("My name is Alice", session=session, stream=True):
|
||||
pass
|
||||
|
||||
chat_messages: list[Message] = session.state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {}).get("messages", [])
|
||||
assert len(chat_messages) == 2
|
||||
assert chat_messages[0].text == "My name is Alice"
|
||||
assert chat_messages[1].text == "Hello Alice!"
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None:
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
|
||||
@@ -3449,3 +3449,66 @@ async def test_streaming_function_calling_response_includes_reasoning_and_tool_r
|
||||
reasoning_contents = [c for msg in response.messages for c in msg.contents if c.type == "text_reasoning"]
|
||||
assert len(reasoning_contents) >= 1
|
||||
assert reasoning_contents[0].id == "rs_test123"
|
||||
|
||||
|
||||
# region _update_conversation_id unit tests
|
||||
|
||||
|
||||
class TestUpdateConversationId:
|
||||
"""Tests for _update_conversation_id handling dict chat_options."""
|
||||
|
||||
def test_chat_options_as_dict(self):
|
||||
"""When chat_options is a plain dict, conversation_id should be set via key access."""
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
kwargs: dict[str, Any] = {"chat_options": {}}
|
||||
_update_conversation_id(kwargs, "conv_1")
|
||||
assert kwargs["chat_options"]["conversation_id"] == "conv_1"
|
||||
|
||||
def test_chat_options_as_typed_dict(self):
|
||||
"""When chat_options is a ChatOptions TypedDict, conversation_id should be set via key access."""
|
||||
from agent_framework import ChatOptions
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
opts: ChatOptions = {"temperature": 0.5}
|
||||
kwargs: dict[str, Any] = {"chat_options": opts}
|
||||
_update_conversation_id(kwargs, "conv_2")
|
||||
assert kwargs["chat_options"]["conversation_id"] == "conv_2"
|
||||
|
||||
def test_no_chat_options_falls_back_to_kwargs(self):
|
||||
"""When chat_options is absent, conversation_id should be set directly on kwargs."""
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
_update_conversation_id(kwargs, "conv_4")
|
||||
assert kwargs["conversation_id"] == "conv_4"
|
||||
|
||||
def test_none_conversation_id_is_noop(self):
|
||||
"""When conversation_id is None, kwargs should not be modified."""
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
kwargs: dict[str, Any] = {"chat_options": {}}
|
||||
_update_conversation_id(kwargs, None)
|
||||
assert "conversation_id" not in kwargs["chat_options"]
|
||||
assert "conversation_id" not in kwargs
|
||||
|
||||
def test_options_dict_also_updated(self):
|
||||
"""The optional options dict should also receive conversation_id."""
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
kwargs: dict[str, Any] = {"chat_options": {}}
|
||||
options: dict[str, Any] = {}
|
||||
_update_conversation_id(kwargs, "conv_5", options)
|
||||
assert kwargs["chat_options"]["conversation_id"] == "conv_5"
|
||||
assert options["conversation_id"] == "conv_5"
|
||||
|
||||
def test_dict_overwrites_existing_conversation_id(self):
|
||||
"""When a dict already has a conversation_id, it should be overwritten."""
|
||||
from agent_framework._tools import _update_conversation_id
|
||||
|
||||
kwargs: dict[str, Any] = {"chat_options": {"conversation_id": "old_id"}}
|
||||
_update_conversation_id(kwargs, "new_id")
|
||||
assert kwargs["chat_options"]["conversation_id"] == "new_id"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -2666,6 +2666,58 @@ class TestResponseStreamBasicIteration:
|
||||
assert stream.updates[0].text == "update_0"
|
||||
assert stream.updates[1].text == "update_1"
|
||||
|
||||
async def test_auto_finalize_on_iteration_completion(self) -> None:
|
||||
"""Stream auto-finalizes when async iteration completes."""
|
||||
stream = ResponseStream(_generate_updates(2), finalizer=_combine_updates)
|
||||
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
assert stream._finalized is True
|
||||
assert stream._final_result is not None
|
||||
assert stream._final_result.text == "update_0update_1"
|
||||
|
||||
async def test_auto_finalize_runs_result_hooks(self) -> None:
|
||||
"""Result hooks run automatically when iteration completes."""
|
||||
hook_called = {"value": False}
|
||||
|
||||
def tracking_hook(response: ChatResponse) -> ChatResponse:
|
||||
hook_called["value"] = True
|
||||
response.additional_properties["auto_finalized"] = True
|
||||
return response
|
||||
|
||||
stream = ResponseStream(
|
||||
_generate_updates(2),
|
||||
finalizer=_combine_updates,
|
||||
result_hooks=[tracking_hook],
|
||||
)
|
||||
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
assert hook_called["value"] is True
|
||||
final = await stream.get_final_response()
|
||||
assert final.additional_properties["auto_finalized"] is True
|
||||
|
||||
async def test_get_final_response_idempotent_after_auto_finalize(self) -> None:
|
||||
"""get_final_response returns cached result after auto-finalization."""
|
||||
call_count = {"value": 0}
|
||||
|
||||
def counting_finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse:
|
||||
call_count["value"] += 1
|
||||
return _combine_updates(updates)
|
||||
|
||||
stream = ResponseStream(_generate_updates(2), finalizer=counting_finalizer)
|
||||
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
final1 = await stream.get_final_response()
|
||||
final2 = await stream.get_final_response()
|
||||
|
||||
assert call_count["value"] == 1
|
||||
assert final1.text == final2.text
|
||||
|
||||
|
||||
class TestResponseStreamTransformHooks:
|
||||
"""Tests for transform hooks (per-update processing)."""
|
||||
|
||||
@@ -1161,6 +1161,21 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools(openai_unit_t
|
||||
assert "parallel_tool_calls" not in prepared_options
|
||||
|
||||
|
||||
def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that conversation_id is excluded from prepared options for chat completions."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
messages = [Message(role="user", text="test")]
|
||||
options = {"conversation_id": "12345", "temperature": 0.7}
|
||||
|
||||
prepared_options = client._prepare_options(messages, options)
|
||||
|
||||
# conversation_id is not a valid parameter for AsyncCompletions.create()
|
||||
assert "conversation_id" not in prepared_options
|
||||
# Other options should still be present
|
||||
assert prepared_options["temperature"] == 0.7
|
||||
|
||||
|
||||
async def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that streaming errors are properly handled."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -544,19 +544,19 @@ class TestFunctionExecutor:
|
||||
static_wrapped = staticmethod(my_async_func)
|
||||
|
||||
# Direct check on descriptor object fails (this is the bug)
|
||||
assert not asyncio.iscoroutinefunction(static_wrapped)
|
||||
assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated]
|
||||
assert isinstance(static_wrapped, staticmethod)
|
||||
|
||||
# But unwrapping __func__ reveals the async function
|
||||
unwrapped = static_wrapped.__func__
|
||||
assert asyncio.iscoroutinefunction(unwrapped)
|
||||
assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated]
|
||||
|
||||
# When accessed via class attribute, Python's descriptor protocol
|
||||
# automatically unwraps it, so it works:
|
||||
class C:
|
||||
async_static = static_wrapped
|
||||
|
||||
assert asyncio.iscoroutinefunction(C.async_static) # Works via descriptor protocol
|
||||
assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol
|
||||
|
||||
|
||||
class TestExecutorExplicitTypes:
|
||||
|
||||
+1
-2
@@ -15,11 +15,10 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from inspect import isawaitable
|
||||
from typing import Any, cast
|
||||
from collections.abc import Callable
|
||||
|
||||
from agent_framework import (
|
||||
Content,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
@@ -26,12 +26,11 @@ from agent_framework._tools import FunctionTool, ToolTypes
|
||||
from agent_framework._types import AgentRunInputs, normalize_tools
|
||||
from agent_framework.exceptions import AgentException
|
||||
from copilot import CopilotClient, CopilotSession
|
||||
from copilot.generated.session_events import SessionEvent, SessionEventType
|
||||
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
|
||||
from copilot.types import (
|
||||
CopilotClientOptions,
|
||||
MCPServerConfig,
|
||||
MessageOptions,
|
||||
PermissionRequest,
|
||||
PermissionRequestResult,
|
||||
ResumeSessionConfig,
|
||||
SessionConfig,
|
||||
@@ -529,7 +528,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
"""Convert an FunctionTool to a Copilot SDK tool."""
|
||||
|
||||
async def handler(invocation: ToolInvocation) -> ToolResult:
|
||||
args = invocation.get("arguments", {})
|
||||
args: dict[str, Any] = invocation.arguments or {}
|
||||
try:
|
||||
if ai_func.input_model:
|
||||
args_instance = ai_func.input_model(**args)
|
||||
@@ -537,13 +536,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
else:
|
||||
result = await ai_func.invoke(arguments=args)
|
||||
return ToolResult(
|
||||
textResultForLlm=str(result),
|
||||
resultType="success",
|
||||
text_result_for_llm=str(result),
|
||||
result_type="success",
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(
|
||||
textResultForLlm=f"Error: {e}",
|
||||
resultType="failure",
|
||||
text_result_for_llm=f"Error: {e}",
|
||||
result_type="failure",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "agent-framework-github-copilot"
|
||||
description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
version = "1.0.0b260304"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
@@ -15,7 +15,6 @@ classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
@@ -24,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc3",
|
||||
"github-copilot-sdk>=0.1.0",
|
||||
"github-copilot-sdk>=0.1.32",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -66,7 +65,7 @@ include = ["agent_framework_github_copilot"]
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
python_version = "3.11"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from copilot.generated.session_events import Data, SessionEvent, SessionEventType
|
||||
from copilot.types import ToolInvocation, ToolResult
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
|
||||
|
||||
@@ -745,10 +746,11 @@ class TestGitHubCopilotAgentToolConversion:
|
||||
config = call_args[0][0]
|
||||
copilot_tool = config["tools"][0]
|
||||
|
||||
result = await copilot_tool.handler({"arguments": {"arg": "test"}})
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
|
||||
|
||||
assert result["resultType"] == "success"
|
||||
assert result["textResultForLlm"] == "Result: test"
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.result_type == "success"
|
||||
assert result.text_result_for_llm == "Result: test"
|
||||
|
||||
async def test_tool_handler_returns_failure_result_on_error(
|
||||
self,
|
||||
@@ -770,11 +772,61 @@ class TestGitHubCopilotAgentToolConversion:
|
||||
config = call_args[0][0]
|
||||
copilot_tool = config["tools"][0]
|
||||
|
||||
result = await copilot_tool.handler({"arguments": {"arg": "test"}})
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"}))
|
||||
|
||||
assert result["resultType"] == "failure"
|
||||
assert "Something went wrong" in result["textResultForLlm"]
|
||||
assert "Something went wrong" in result["error"]
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.result_type == "failure"
|
||||
assert "Something went wrong" in result.text_result_for_llm
|
||||
assert "Something went wrong" in result.error
|
||||
|
||||
async def test_tool_handler_rejects_raw_dict_invocation(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that tool handler raises TypeError when called with a raw dict instead of ToolInvocation."""
|
||||
|
||||
def my_tool(arg: str) -> str:
|
||||
"""A test tool."""
|
||||
return f"Result: {arg}"
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client, tools=[my_tool])
|
||||
await agent.start()
|
||||
|
||||
await agent._get_or_create_session(AgentSession()) # type: ignore
|
||||
|
||||
call_args = mock_client.create_session.call_args
|
||||
config = call_args[0][0]
|
||||
copilot_tool = config["tools"][0]
|
||||
|
||||
with pytest.raises((TypeError, AttributeError)):
|
||||
await copilot_tool.handler({"arguments": {"arg": "test"}})
|
||||
|
||||
async def test_tool_handler_with_empty_arguments(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that tool handler handles ToolInvocation with empty arguments."""
|
||||
|
||||
def no_args_tool() -> str:
|
||||
"""A tool with no arguments."""
|
||||
return "no args result"
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client, tools=[no_args_tool])
|
||||
await agent.start()
|
||||
|
||||
await agent._get_or_create_session(AgentSession()) # type: ignore
|
||||
|
||||
call_args = mock_client.create_session.call_args
|
||||
config = call_args[0][0]
|
||||
copilot_tool = config["tools"][0]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={}))
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.result_type == "success"
|
||||
assert result.text_result_for_llm == "no args result"
|
||||
|
||||
def test_copilot_tool_passthrough(
|
||||
self,
|
||||
@@ -784,7 +836,7 @@ class TestGitHubCopilotAgentToolConversion:
|
||||
from copilot.types import Tool as CopilotTool
|
||||
|
||||
async def tool_handler(invocation: Any) -> Any:
|
||||
return {"textResultForLlm": "result", "resultType": "success"}
|
||||
return {"text_result_for_llm": "result", "result_type": "success"}
|
||||
|
||||
copilot_tool = CopilotTool(
|
||||
name="direct_tool",
|
||||
@@ -813,7 +865,7 @@ class TestGitHubCopilotAgentToolConversion:
|
||||
return arg
|
||||
|
||||
async def tool_handler(invocation: Any) -> Any:
|
||||
return {"textResultForLlm": "result", "resultType": "success"}
|
||||
return {"text_result_for_llm": "result", "result_type": "success"}
|
||||
|
||||
copilot_tool = CopilotTool(
|
||||
name="direct_tool",
|
||||
|
||||
@@ -22,7 +22,7 @@ export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="gpt-4o" # optional, defaults to
|
||||
|---|------|-------------------|
|
||||
| 1 | [01_hello_agent.py](01_hello_agent.py) | Create your first agent and run it (streaming and non-streaming). |
|
||||
| 2 | [02_add_tools.py](02_add_tools.py) | Define a function tool with `@tool` and attach it to an agent. |
|
||||
| 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentThread`. |
|
||||
| 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentSession`. |
|
||||
| 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. |
|
||||
| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. |
|
||||
| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Host a single agent with Azure Functions. |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -28,7 +29,7 @@ async def main() -> None:
|
||||
client = OpenAIChatClient()
|
||||
|
||||
try:
|
||||
task = asyncio.create_task(client.get_response(messages=["Tell me a fantasy story."]))
|
||||
task = asyncio.create_task(client.get_response(messages=[Message(role="user", text="Tell me a fantasy story.")]))
|
||||
await asyncio.sleep(1)
|
||||
task.cancel()
|
||||
await task
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Azure AI Agent Examples
|
||||
|
||||
This folder contains examples demonstrating different ways to create and use agents with the Azure AI client from the `agent_framework.azure` package. These examples use the `AzureAIClient` with the `azure-ai-projects` 2.x (V2) API surface (see [changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11)). For V1 (`azure-ai-agents` 1.x) samples using `AzureAIAgentClient`, see the [Azure AI V1 examples folder](../azure_ai_agent/).
|
||||
This folder contains examples demonstrating different ways to create and use agents with the Azure AI client from the `agent_framework.azure` package. These examples use the `AzureAIClient` with the `azure-ai-projects` 2.x (V2) API surface (see [changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/CHANGELOG.md#200b1-2025-11-11)). For V1 (`azure-ai-agents` 1.x) samples using `AzureAIAgentClient`, see the [Azure AI V1 examples folder](../azure_ai_agent/). When using preview-only agent creation features on GA SDK versions, create `AIProjectClient` with `allow_preview=True`.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
+8
-6
@@ -15,16 +15,18 @@ SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.types import PermissionRequest, PermissionRequestResult
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.types import PermissionRequestResult
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
def prompt_permission(
|
||||
request: PermissionRequest, context: dict[str, str]
|
||||
) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
kind = request.get("kind", "unknown")
|
||||
print(f"\n[Permission Request: {kind}]")
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if "path" in request:
|
||||
print(f" Path: {request.get('path')}")
|
||||
if request.path is not None:
|
||||
print(f" Path: {request.path}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
|
||||
@@ -15,7 +15,8 @@ of MCP-related actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.types import MCPServerConfig, PermissionRequest, PermissionRequestResult
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.types import MCPServerConfig, PermissionRequestResult
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
@@ -24,8 +25,7 @@ load_dotenv()
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
kind = request.get("kind", "unknown")
|
||||
print(f"\n[Permission Request: {kind}]")
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
|
||||
+7
-7
@@ -21,18 +21,18 @@ More permissions mean more potential for unintended actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.types import PermissionRequest, PermissionRequestResult
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.types import PermissionRequestResult
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
kind = request.get("kind", "unknown")
|
||||
print(f"\n[Permission Request: {kind}]")
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if "command" in request:
|
||||
print(f" Command: {request.get('command')}")
|
||||
if "path" in request:
|
||||
print(f" Path: {request.get('path')}")
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
if request.path is not None:
|
||||
print(f" Path: {request.path}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
|
||||
@@ -14,16 +14,16 @@ Shell commands have full access to your system within the permissions of the run
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.types import PermissionRequest, PermissionRequestResult
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.types import PermissionRequestResult
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
kind = request.get("kind", "unknown")
|
||||
print(f"\n[Permission Request: {kind}]")
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if "command" in request:
|
||||
print(f" Command: {request.get('command')}")
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
|
||||
@@ -14,16 +14,16 @@ URL fetching allows the agent to access any URL accessible from your network.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.types import PermissionRequest, PermissionRequestResult
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.types import PermissionRequestResult
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
kind = request.get("kind", "unknown")
|
||||
print(f"\n[Permission Request: {kind}]")
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if "url" in request:
|
||||
print(f" URL: {request.get('url')}")
|
||||
if request.url is not None:
|
||||
print(f" URL: {request.url}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
# A2A Agent Examples
|
||||
|
||||
This folder contains examples demonstrating how to create and use agents with the A2A (Agent2Agent) protocol from the `agent_framework` package to communicate with remote A2A agents.
|
||||
This sample demonstrates how to host and consume agents using the [A2A (Agent2Agent) protocol](https://a2a-protocol.org/latest/) with the `agent_framework` package. There are two runnable entry points:
|
||||
|
||||
By default the A2AAgent waits for the remote agent to finish before returning (`background=False`), so long-running A2A tasks are handled transparently. For advanced scenarios where you need to poll or resubscribe to in-progress tasks using continuation tokens, see the [background responses sample](../../02-agents/background_responses.py).
|
||||
| Run this file | To... |
|
||||
|---------------|-------|
|
||||
| **[`a2a_server.py`](a2a_server.py)** | Host an Agent Framework agent as an A2A-compliant server. |
|
||||
| **[`agent_with_a2a.py`](agent_with_a2a.py)** | Connect to an A2A server and send requests (non-streaming and streaming). |
|
||||
|
||||
For more information about the A2A protocol specification, visit: https://a2a-protocol.org/latest/
|
||||
|
||||
## Examples
|
||||
The remaining files are supporting modules used by the server:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`agent_with_a2a.py`](agent_with_a2a.py) | Demonstrates agent discovery, non-streaming and streaming responses using the A2A protocol. |
|
||||
| [`agent_definitions.py`](agent_definitions.py) | Agent and AgentCard factory definitions for invoice, policy, and logistics agents. |
|
||||
| [`agent_executor.py`](agent_executor.py) | Bridges the a2a-sdk `AgentExecutor` interface to Agent Framework agents. |
|
||||
| [`invoice_data.py`](invoice_data.py) | Mock invoice data and tool functions for the invoice agent. |
|
||||
| [`a2a_server.http`](a2a_server.http) | REST Client requests for testing the server directly from VS Code. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure to set the following environment variables before running the example:
|
||||
Make sure to set the following environment variables before running the examples:
|
||||
|
||||
### Required
|
||||
- `A2A_AGENT_HOST`: URL of a single A2A agent (for simple sample, e.g., `http://localhost:5001/`)
|
||||
### Required (Server)
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint
|
||||
- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. `gpt-4o`)
|
||||
|
||||
### Required (Client)
|
||||
- `A2A_AGENT_HOST` — URL of the A2A server (e.g. `http://localhost:5001/`)
|
||||
|
||||
## Quick Testing with .NET A2A Servers
|
||||
## Quick Start
|
||||
|
||||
For quick testing and demonstration, you can use the pre-built .NET A2A servers from this repository:
|
||||
All commands below should be run from this directory:
|
||||
|
||||
**Quick Testing Reference**: Use the .NET A2A Client Server sample at:
|
||||
`..\agent-framework\dotnet\samples\05-end-to-end\A2AClientServer`
|
||||
|
||||
### Run Python A2A Sample
|
||||
```powershell
|
||||
# Simple A2A sample (single agent)
|
||||
cd python/samples/04-hosting/a2a
|
||||
```
|
||||
|
||||
### 1. Start the A2A Server
|
||||
|
||||
Pick an agent type and start the server (each in its own terminal):
|
||||
|
||||
```powershell
|
||||
uv run python a2a_server.py --agent-type invoice --port 5000
|
||||
uv run python a2a_server.py --agent-type policy --port 5001
|
||||
uv run python a2a_server.py --agent-type logistics --port 5002
|
||||
```
|
||||
|
||||
You can run one agent or all three — each listens on its own port.
|
||||
|
||||
### 2. Run the A2A Client
|
||||
|
||||
In a separate terminal (from the same directory), point the client at a running server:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST = "http://localhost:5001/"
|
||||
uv run python agent_with_a2a.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
### Each A2A agent is available at a different host address
|
||||
@hostInvoice = http://localhost:5000
|
||||
@hostPolicy = http://localhost:5001
|
||||
@hostLogistics = http://localhost:5002
|
||||
|
||||
### Query agent card for the invoice agent
|
||||
GET {{hostInvoice}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the invoice agent
|
||||
POST {{hostInvoice}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "1",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_1",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Show me all invoices for Contoso"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Query agent card for the policy agent
|
||||
GET {{hostPolicy}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the policy agent
|
||||
POST {{hostPolicy}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "2",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_2",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "What is the policy for short shipments?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Query agent card for the logistics agent
|
||||
GET {{hostLogistics}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the logistics agent
|
||||
POST {{hostLogistics}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "3",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_3",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "What is the status for SHPMT-SAP-001?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
|
||||
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
|
||||
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
|
||||
from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES
|
||||
from agent_executor import AgentFrameworkExecutor
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
A2A Server Sample — Host an Agent Framework agent as an A2A endpoint
|
||||
|
||||
This sample creates a Python-based A2A-compliant server that wraps an Agent
|
||||
Framework agent. The server uses the a2a-sdk's Starlette application to handle
|
||||
JSON-RPC requests and serves the AgentCard at /.well-known/agent.json.
|
||||
|
||||
Three agent types are available:
|
||||
- invoice — Answers invoice queries using mock data and function tools.
|
||||
- policy — Returns a fixed policy response.
|
||||
- logistics — Returns a fixed logistics response.
|
||||
|
||||
Usage:
|
||||
uv run python a2a_server.py --agent-type policy --port 5001
|
||||
uv run python a2a_server.py --agent-type invoice --port 5000
|
||||
uv run python a2a_server.py --agent-type logistics --port 5002
|
||||
|
||||
Environment variables:
|
||||
AZURE_AI_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME — Model deployment name (e.g. gpt-4o)
|
||||
"""
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="A2A Agent Server")
|
||||
parser.add_argument(
|
||||
"--agent-type",
|
||||
choices=["invoice", "policy", "logistics"],
|
||||
default="policy",
|
||||
help="Type of agent to host (default: policy)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="localhost",
|
||||
help="Host to bind to (default: localhost)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=5001,
|
||||
help="Port to listen on (default: 5001)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
# Validate environment
|
||||
project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
|
||||
deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME")
|
||||
|
||||
if not project_endpoint:
|
||||
print("Error: AZURE_AI_PROJECT_ENDPOINT environment variable is not set.")
|
||||
sys.exit(1)
|
||||
if not deployment_name:
|
||||
print("Error: AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME environment variable is not set.")
|
||||
sys.exit(1)
|
||||
|
||||
# Create the LLM client
|
||||
credential = AzureCliCredential()
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_endpoint=project_endpoint,
|
||||
deployment_name=deployment_name,
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# Create the Agent Framework agent for the chosen type
|
||||
agent_factory = AGENT_FACTORIES[args.agent_type]
|
||||
agent = agent_factory(client)
|
||||
|
||||
# Build the A2A server components
|
||||
url = f"http://{args.host}:{args.port}/"
|
||||
agent_card = AGENT_CARD_FACTORIES[args.agent_type](url)
|
||||
executor = AgentFrameworkExecutor(agent)
|
||||
task_store = InMemoryTaskStore()
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=task_store,
|
||||
)
|
||||
|
||||
a2a_app = A2AStarletteApplication(
|
||||
agent_card=agent_card,
|
||||
http_handler=request_handler,
|
||||
)
|
||||
|
||||
print(f"Starting A2A server: {agent_card.name}")
|
||||
print(f" Agent type : {args.agent_type}")
|
||||
print(f" Listening : {url}")
|
||||
print(f" Agent card : {url}.well-known/agent.json")
|
||||
print()
|
||||
|
||||
uvicorn.run(
|
||||
a2a_app.build(),
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent definitions and AgentCard factories for the A2A server sample.
|
||||
|
||||
Provides factory functions to create Agent Framework agents and A2A
|
||||
AgentCards for the invoice, policy, and logistics agent types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
|
||||
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent instructions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INVOICE_INSTRUCTIONS = "You specialize in handling queries related to invoices."
|
||||
|
||||
POLICY_INSTRUCTIONS = """\
|
||||
You specialize in handling queries related to policies and customer communications.
|
||||
|
||||
Always reply with exactly this text:
|
||||
|
||||
Policy: Short Shipment Dispute Handling Policy V2.1
|
||||
|
||||
Summary: "For short shipments reported by customers, first verify internal shipment records
|
||||
(SAP) and physical logistics scan data (BigQuery). If discrepancy is confirmed and logistics data
|
||||
shows fewer items packed than invoiced, issue a credit for the missing items. Document the
|
||||
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
|
||||
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
|
||||
template."
|
||||
"""
|
||||
|
||||
LOGISTICS_INSTRUCTIONS = """\
|
||||
You specialize in handling queries related to logistics.
|
||||
|
||||
Always reply with exactly:
|
||||
|
||||
Shipment number: SHPMT-SAP-001
|
||||
Item: TSHIRT-RED-L
|
||||
Quantity: 900
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_invoice_agent(client: AzureOpenAIResponsesClient) -> Agent:
|
||||
"""Create an invoice agent backed by the given client with query tools."""
|
||||
return client.as_agent(
|
||||
name="InvoiceAgent",
|
||||
instructions=INVOICE_INSTRUCTIONS,
|
||||
tools=[query_invoices, query_by_transaction_id, query_by_invoice_id],
|
||||
)
|
||||
|
||||
|
||||
def create_policy_agent(client: AzureOpenAIResponsesClient) -> Agent:
|
||||
"""Create a policy agent backed by the given client."""
|
||||
return client.as_agent(
|
||||
name="PolicyAgent",
|
||||
instructions=POLICY_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
|
||||
def create_logistics_agent(client: AzureOpenAIResponsesClient) -> Agent:
|
||||
"""Create a logistics agent backed by the given client."""
|
||||
return client.as_agent(
|
||||
name="LogisticsAgent",
|
||||
instructions=LOGISTICS_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentCard factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CAPABILITIES = AgentCapabilities(streaming=True, push_notifications=False)
|
||||
|
||||
|
||||
def get_invoice_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the invoice agent."""
|
||||
return AgentCard(
|
||||
name="InvoiceAgent",
|
||||
description="Handles requests relating to invoices.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_invoice_agent",
|
||||
name="InvoiceQuery",
|
||||
description="Handles requests relating to invoices.",
|
||||
tags=["invoice", "agent-framework"],
|
||||
examples=["List the latest invoices for Contoso."],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_policy_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the policy agent."""
|
||||
return AgentCard(
|
||||
name="PolicyAgent",
|
||||
description="Handles requests relating to policies and customer communications.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_policy_agent",
|
||||
name="PolicyAgent",
|
||||
description="Handles requests relating to policies and customer communications.",
|
||||
tags=["policy", "agent-framework"],
|
||||
examples=["What is the policy for short shipments?"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_logistics_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the logistics agent."""
|
||||
return AgentCard(
|
||||
name="LogisticsAgent",
|
||||
description="Handles requests relating to logistics.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_logistics_agent",
|
||||
name="LogisticsQuery",
|
||||
description="Handles requests relating to logistics.",
|
||||
tags=["logistics", "agent-framework"],
|
||||
examples=["What is the status for SHPMT-SAP-001"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AGENT_FACTORIES = {
|
||||
"invoice": create_invoice_agent,
|
||||
"policy": create_policy_agent,
|
||||
"logistics": create_logistics_agent,
|
||||
}
|
||||
|
||||
AGENT_CARD_FACTORIES = {
|
||||
"invoice": get_invoice_agent_card,
|
||||
"policy": get_policy_agent_card,
|
||||
"logistics": get_logistics_agent_card,
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AgentExecutor bridge between the a2a-sdk server and Agent Framework agents.
|
||||
|
||||
Implements the a2a-sdk ``AgentExecutor`` interface so that incoming A2A
|
||||
requests are forwarded to an Agent Framework agent and the response is
|
||||
published back through the a2a-sdk event queue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from a2a.server.agent_execution.agent_executor import AgentExecutor
|
||||
from a2a.types import (
|
||||
Message,
|
||||
Part,
|
||||
Role,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TaskStatusUpdateEvent,
|
||||
TextPart,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from a2a.server.agent_execution.context import RequestContext
|
||||
from a2a.server.events.event_queue import EventQueue
|
||||
from agent_framework import Agent
|
||||
|
||||
|
||||
class AgentFrameworkExecutor(AgentExecutor):
|
||||
"""Bridges A2A protocol requests to an Agent Framework agent.
|
||||
|
||||
For each incoming ``execute`` call the executor:
|
||||
1. Extracts the user's text from the A2A ``RequestContext``.
|
||||
2. Runs the Agent Framework agent (non-streaming).
|
||||
3. Publishes the result as an A2A ``Message`` to the ``EventQueue``.
|
||||
"""
|
||||
|
||||
def __init__(self, agent: Agent) -> None:
|
||||
self.agent = agent
|
||||
|
||||
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Run the agent and publish the response."""
|
||||
user_text = context.get_user_input()
|
||||
if not user_text:
|
||||
user_text = "Hello"
|
||||
|
||||
task_id = context.task_id or str(uuid.uuid4())
|
||||
context_id = context.context_id or str(uuid.uuid4())
|
||||
|
||||
# Signal that the agent is working
|
||||
await event_queue.enqueue_event(
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(state=TaskState.working),
|
||||
final=False,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.agent.run(user_text)
|
||||
|
||||
# Build response text from agent messages
|
||||
response_parts: list[Part] = []
|
||||
for msg in response.messages:
|
||||
if msg.text:
|
||||
response_parts.append(TextPart(text=msg.text))
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append(TextPart(text=str(response)))
|
||||
|
||||
# Publish the agent's response as a completed message
|
||||
await event_queue.enqueue_event(
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(
|
||||
state=TaskState.completed,
|
||||
message=Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
role=Role.agent,
|
||||
parts=response_parts,
|
||||
),
|
||||
),
|
||||
final=True,
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
await event_queue.enqueue_event(
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(
|
||||
state=TaskState.failed,
|
||||
message=Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
role=Role.agent,
|
||||
parts=[TextPart(text=f"Agent error: {e}")],
|
||||
),
|
||||
),
|
||||
final=True,
|
||||
)
|
||||
)
|
||||
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Handle cancellation by publishing a canceled status."""
|
||||
task_id = context.task_id or str(uuid.uuid4())
|
||||
context_id = context.context_id or str(uuid.uuid4())
|
||||
|
||||
await event_queue.enqueue_event(
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(state=TaskState.canceled),
|
||||
final=True,
|
||||
)
|
||||
)
|
||||
@@ -78,16 +78,16 @@ async def main():
|
||||
# Updates arrive as Server-Sent Events, letting you observe
|
||||
# progress in real time as the remote agent works.
|
||||
print("\n--- Streaming response ---")
|
||||
async with agent.run("Tell me about yourself", stream=True) as stream:
|
||||
async for update in stream:
|
||||
for content in update.contents:
|
||||
if content.text:
|
||||
print(f" {content.text}")
|
||||
stream = agent.run("Tell me about yourself", stream=True)
|
||||
async for update in stream:
|
||||
for content in update.contents:
|
||||
if content.text:
|
||||
print(f" {content.text}")
|
||||
|
||||
response = await stream.get_final_response()
|
||||
print(f"\nFinal response ({len(response.messages)} message(s)):")
|
||||
for message in response.messages:
|
||||
print(f" {message.text}")
|
||||
response = await stream.get_final_response()
|
||||
print(f"\nFinal response ({len(response.messages)} message(s)):")
|
||||
for message in response.messages:
|
||||
print(f" {message.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mock invoice data and tool functions for the A2A server sample.
|
||||
|
||||
Provides mock invoice data and query tools for the A2A server sample,
|
||||
enabling invoice-related queries through the A2A protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import tool
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Product:
|
||||
"""A product line item on an invoice."""
|
||||
|
||||
name: str
|
||||
quantity: int
|
||||
price_per_unit: float
|
||||
|
||||
@property
|
||||
def total_price(self) -> float:
|
||||
return self.quantity * self.price_per_unit
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"quantity": self.quantity,
|
||||
"price_per_unit": self.price_per_unit,
|
||||
"total_price": self.total_price,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invoice:
|
||||
"""An invoice record with products."""
|
||||
|
||||
transaction_id: str
|
||||
invoice_id: str
|
||||
company_name: str
|
||||
invoice_date: datetime
|
||||
products: list[Product] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_invoice_price(self) -> float:
|
||||
return sum(p.total_price for p in self.products)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"transaction_id": self.transaction_id,
|
||||
"invoice_id": self.invoice_id,
|
||||
"company_name": self.company_name,
|
||||
"invoice_date": self.invoice_date.strftime("%Y-%m-%d"),
|
||||
"products": [p.to_dict() for p in self.products],
|
||||
"total_invoice_price": self.total_invoice_price,
|
||||
}
|
||||
|
||||
|
||||
def _random_date_within_last_two_months() -> datetime:
|
||||
end_date = datetime.now(timezone.utc)
|
||||
start_date = end_date - timedelta(days=60)
|
||||
random_days = random.randint(0, 60)
|
||||
return start_date + timedelta(days=random_days)
|
||||
|
||||
|
||||
def _build_invoices() -> list[Invoice]:
|
||||
"""Build 10 mock invoices."""
|
||||
return [
|
||||
Invoice("TICKET-XYZ987", "INV789", "Contoso", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 150, 10.00),
|
||||
Product("Hats", 200, 15.00),
|
||||
Product("Glasses", 300, 5.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ111", "INV111", "XStore", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 2500, 12.00),
|
||||
Product("Hats", 1500, 8.00),
|
||||
Product("Glasses", 200, 20.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ222", "INV222", "Cymbal Direct", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 1200, 14.00),
|
||||
Product("Hats", 800, 7.00),
|
||||
Product("Glasses", 500, 25.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ333", "INV333", "Contoso", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 400, 11.00),
|
||||
Product("Hats", 600, 15.00),
|
||||
Product("Glasses", 700, 5.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ444", "INV444", "XStore", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 800, 10.00),
|
||||
Product("Hats", 500, 18.00),
|
||||
Product("Glasses", 300, 22.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ555", "INV555", "Cymbal Direct", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 1100, 9.00),
|
||||
Product("Hats", 900, 12.00),
|
||||
Product("Glasses", 1200, 15.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ666", "INV666", "Contoso", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 2500, 8.00),
|
||||
Product("Hats", 1200, 10.00),
|
||||
Product("Glasses", 1000, 6.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ777", "INV777", "XStore", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 1900, 13.00),
|
||||
Product("Hats", 1300, 16.00),
|
||||
Product("Glasses", 800, 19.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ888", "INV888", "Cymbal Direct", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 2200, 11.00),
|
||||
Product("Hats", 1700, 8.50),
|
||||
Product("Glasses", 600, 21.00),
|
||||
]),
|
||||
Invoice("TICKET-XYZ999", "INV999", "Contoso", _random_date_within_last_two_months(), [
|
||||
Product("T-Shirts", 1400, 10.50),
|
||||
Product("Hats", 1100, 9.00),
|
||||
Product("Glasses", 950, 12.00),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# Module-level singleton so dates are stable for the lifetime of the server
|
||||
INVOICES = _build_invoices()
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_invoices(
|
||||
company_name: Annotated[str, Field(description="The company name to filter invoices by.")],
|
||||
start_date: Annotated[str | None, Field(description="Optional start date (YYYY-MM-DD) to filter invoices.")] = None,
|
||||
end_date: Annotated[str | None, Field(description="Optional end date (YYYY-MM-DD) to filter invoices.")] = None,
|
||||
) -> str:
|
||||
"""Retrieves invoices for the specified company and optionally within the specified time range."""
|
||||
results = [i for i in INVOICES if i.company_name.lower() == company_name.lower()]
|
||||
|
||||
if start_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
results = [i for i in results if i.invoice_date >= start]
|
||||
|
||||
if end_date:
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
results = [i for i in results if i.invoice_date < end]
|
||||
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_by_transaction_id(
|
||||
transaction_id: Annotated[str, Field(description="The transaction ID to look up (e.g. TICKET-XYZ987).")],
|
||||
) -> str:
|
||||
"""Retrieves invoice using the transaction id."""
|
||||
results = [i for i in INVOICES if i.transaction_id.lower() == transaction_id.lower()]
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_by_invoice_id(
|
||||
invoice_id: Annotated[str, Field(description="The invoice ID to look up (e.g. INV789).")],
|
||||
) -> str:
|
||||
"""Retrieves invoice using the invoice id."""
|
||||
results = [i for i in INVOICES if i.invoice_id.lower() == invoice_id.lower()]
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
@@ -18,7 +18,7 @@ Start with `01-get-started/` and work through the numbered files:
|
||||
|
||||
1. **[01_hello_agent.py](./01-get-started/01_hello_agent.py)** — Create and run your first agent
|
||||
2. **[02_add_tools.py](./01-get-started/02_add_tools.py)** — Add function tools with `@tool`
|
||||
3. **[03_multi_turn.py](./01-get-started/03_multi_turn.py)** — Multi-turn conversations with `AgentThread`
|
||||
3. **[03_multi_turn.py](./01-get-started/03_multi_turn.py)** — Multi-turn conversations with `AgentSession`
|
||||
4. **[04_memory.py](./01-get-started/04_memory.py)** — Agent memory with `ContextProvider`
|
||||
5. **[05_first_workflow.py](./01-get-started/05_first_workflow.py)** — Build a workflow with executors and edges
|
||||
6. **[06_host_your_agent.py](./01-get-started/06_host_your_agent.py)** — Host your agent via Azure Functions
|
||||
|
||||
Generated
+291
-1178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user