mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ff34cc17 | ||
|
|
adcd2d33f5 | ||
|
|
d5777bc546 | ||
|
|
b6b191ad9c |
@@ -4,7 +4,6 @@ using System;
|
|||||||
using System.ClientModel.Primitives;
|
using System.ClientModel.Primitives;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
@@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
|||||||
include: null,
|
include: null,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return newItems.AsChatMessages().Single();
|
ChatMessage[] createdMessages = [.. newItems.AsChatMessages()];
|
||||||
|
if (createdMessages.Length != 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdMessages[0];
|
||||||
|
|
||||||
IEnumerable<ResponseItem> GetResponseItems()
|
IEnumerable<ResponseItem> GetResponseItems()
|
||||||
{
|
{
|
||||||
@@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
|||||||
{
|
{
|
||||||
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
|
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
|
||||||
ResponseItem[] items = [responseItem.AsResponseResultItem()];
|
ResponseItem[] items = [responseItem.AsResponseResultItem()];
|
||||||
return items.AsChatMessages().Single();
|
ChatMessage[] messages = [.. items.AsChatMessages()];
|
||||||
|
if (messages.Length != 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
+17
-9
@@ -49,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
|||||||
|
|
||||||
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
|
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
|
ChatMessage? lastMessage = response.Messages.LastOrDefault();
|
||||||
|
if (lastMessage is not null)
|
||||||
|
{
|
||||||
|
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
|
||||||
|
}
|
||||||
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
|
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
|||||||
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
|
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
|
||||||
|
|
||||||
// Attempt to parse the last message as JSON and assign to the response object variable.
|
// Attempt to parse the last message as JSON and assign to the response object variable.
|
||||||
try
|
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
|
||||||
|
if (!string.IsNullOrEmpty(lastMessageText))
|
||||||
{
|
{
|
||||||
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
|
try
|
||||||
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
|
{
|
||||||
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
|
using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText);
|
||||||
}
|
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
|
||||||
catch
|
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
|
||||||
{
|
}
|
||||||
// Not valid json, skip assignment.
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not valid json, skip assignment.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.Model.Input?.ExternalLoop?.When is not null)
|
if (this.Model.Input?.ExternalLoop?.When is not null)
|
||||||
|
|||||||
+7
-4
@@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
|||||||
string? workflowConversationId = context.GetWorkflowConversation();
|
string? workflowConversationId = context.GetWorkflowConversation();
|
||||||
if (workflowConversationId is not null)
|
if (workflowConversationId is not null)
|
||||||
{
|
{
|
||||||
// Input message always defined if values has been extracted.
|
// Input message expected to be defined when values have been extracted, but guard defensively.
|
||||||
ChatMessage input = response.Messages.Last();
|
ChatMessage? input = response.Messages.LastOrDefault();
|
||||||
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
|
if (input is not null)
|
||||||
await context.SetLastMessageAsync(input).ConfigureAwait(false);
|
{
|
||||||
|
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
|
||||||
|
await context.SetLastMessageAsync(input).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
|
|||||||
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
|
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
|
ChatMessage? lastMessage = response.Messages.LastOrDefault();
|
||||||
|
if (lastMessage is not null)
|
||||||
|
{
|
||||||
|
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
|
||||||
|
}
|
||||||
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
|
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
|
||||||
|
|
||||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ internal sealed class HandoffAgentExecutor :
|
|||||||
{
|
{
|
||||||
AgentId = this._agent.Id,
|
AgentId = this._agent.Id,
|
||||||
AuthorName = this._agent.Name ?? this._agent.Id,
|
AuthorName = this._agent.Name ?? this._agent.Id,
|
||||||
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
|
Contents = [CreateHandoffResult(handoffRequest.CallId)],
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
MessageId = Guid.NewGuid().ToString("N"),
|
MessageId = Guid.NewGuid().ToString("N"),
|
||||||
Role = ChatRole.Tool,
|
Role = ChatRole.Tool,
|
||||||
@@ -459,4 +459,6 @@ internal sealed class HandoffAgentExecutor :
|
|||||||
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
|
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||||
@@ -31,113 +30,78 @@ internal sealed class HandoffMessagesFilter
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
Dictionary<string, FilterCandidateState> filteringCandidates = new();
|
HashSet<string> filteredCallsWithoutResponses = new();
|
||||||
List<ChatMessage> filteredMessages = [];
|
List<ChatMessage> retainedMessages = [];
|
||||||
HashSet<int> messagesToRemove = [];
|
|
||||||
|
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
|
||||||
|
|
||||||
|
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
|
||||||
|
// We are going to assume that Handoff operates as follows:
|
||||||
|
// * Each agent is only taking one turn at a time
|
||||||
|
// * Each agent is taking a turn alone
|
||||||
|
//
|
||||||
|
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
|
||||||
|
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
|
||||||
|
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
|
||||||
|
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
|
||||||
|
//
|
||||||
|
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
|
||||||
|
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
|
||||||
|
|
||||||
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
|
|
||||||
foreach (ChatMessage unfilteredMessage in messages)
|
foreach (ChatMessage unfilteredMessage in messages)
|
||||||
{
|
{
|
||||||
ChatMessage filteredMessage = unfilteredMessage.Clone();
|
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
|
||||||
|
|
||||||
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
|
|
||||||
List<AIContent> contents = [];
|
|
||||||
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
|
|
||||||
filteredMessage.Contents = contents;
|
|
||||||
|
|
||||||
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
|
|
||||||
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
|
|
||||||
// FunctionCallContent.
|
|
||||||
if (unfilteredMessage.Role != ChatRole.Tool)
|
|
||||||
{
|
{
|
||||||
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
|
retainedMessages.Add(unfilteredMessage);
|
||||||
{
|
continue;
|
||||||
AIContent content = unfilteredMessage.Contents[i];
|
|
||||||
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
|
|
||||||
{
|
|
||||||
filteredMessage.Contents.Add(content);
|
|
||||||
|
|
||||||
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
|
|
||||||
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
|
|
||||||
{
|
|
||||||
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
|
|
||||||
{
|
|
||||||
IsHandoffFunction = false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (filterHandoffOnly)
|
|
||||||
{
|
|
||||||
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
|
|
||||||
{
|
|
||||||
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
|
|
||||||
{
|
|
||||||
IsHandoffFunction = true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
candidateState.IsHandoffFunction = true;
|
|
||||||
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
|
|
||||||
ChatMessage messageToFilter = filteredMessages[messageIndex];
|
|
||||||
messageToFilter.Contents.RemoveAt(contentIndex);
|
|
||||||
if (messageToFilter.Contents.Count == 0)
|
|
||||||
{
|
|
||||||
messagesToRemove.Add(messageIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// All mode: strip all FunctionCallContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
|
||||||
|
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
|
||||||
|
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
|
||||||
|
|
||||||
|
foreach (AIContent content in unfilteredMessage.Contents)
|
||||||
{
|
{
|
||||||
if (!filterHandoffOnly)
|
if (content is FunctionCallContent fcc
|
||||||
|
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
|
||||||
{
|
{
|
||||||
|
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
|
||||||
|
// which violates our assumption of strict ordering.
|
||||||
|
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
|
||||||
|
// filter this FCC
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
else if (content is FunctionResultContent frc)
|
||||||
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
|
|
||||||
{
|
{
|
||||||
AIContent content = unfilteredMessage.Contents[i];
|
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
|
||||||
if (content is not FunctionResultContent frc
|
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
|
||||||
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
|
// come in with the same CallId, and should be considered a new call that may need to be filtered.
|
||||||
&& candidateState.IsHandoffFunction is false))
|
if (filteredCallsWithoutResponses.Remove(frc.CallId))
|
||||||
{
|
{
|
||||||
// Either this is not a function result content, so we should let it through, or it is a FRC that
|
continue;
|
||||||
// we know is not related to a handoff call. In either case, we should include it.
|
|
||||||
filteredMessage.Contents.Add(content);
|
|
||||||
}
|
}
|
||||||
else if (candidateState is null)
|
|
||||||
{
|
|
||||||
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
|
|
||||||
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
|
|
||||||
{
|
|
||||||
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
|
||||||
|
retainedContents.Add(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredMessage.Contents.Count > 0)
|
if (retainedContents.Count == 0)
|
||||||
{
|
{
|
||||||
filteredMessages.Add(filteredMessage);
|
// message was fully filtered, skip it
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatMessage filteredMessage = unfilteredMessage.Clone();
|
||||||
|
filteredMessage.Contents = retainedContents;
|
||||||
|
retainedMessages.Add(filteredMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
|
return retainedMessages;
|
||||||
}
|
|
||||||
|
|
||||||
private class FilterCandidateState(string callId)
|
|
||||||
{
|
|
||||||
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
|
|
||||||
|
|
||||||
public string CallId => callId;
|
|
||||||
|
|
||||||
public bool? IsHandoffFunction { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -85,6 +85,29 @@ public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) :
|
|||||||
expectMessagesCreated: true);
|
expectMessagesCreated: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CaptureResponseWithEmptyMessagesAsync()
|
||||||
|
{
|
||||||
|
await this.CaptureResponseTestAsync(
|
||||||
|
displayName: nameof(CaptureResponseWithEmptyMessagesAsync),
|
||||||
|
variableName: "TestVariable",
|
||||||
|
messageCount: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await this.CaptureResponseTestAsync(
|
||||||
|
displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync),
|
||||||
|
variableName: "TestVariable",
|
||||||
|
messageCount: 0,
|
||||||
|
expectMessagesCreated: false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ExecuteTestAsync(
|
private async Task ExecuteTestAsync(
|
||||||
string displayName,
|
string displayName,
|
||||||
string variableName)
|
string variableName)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||||
|
|
||||||
|
public class HandoffMessageFilterTests
|
||||||
|
{
|
||||||
|
private List<ChatMessage> CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None)
|
||||||
|
{
|
||||||
|
FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId);
|
||||||
|
FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1);
|
||||||
|
|
||||||
|
FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId);
|
||||||
|
FunctionResultContent toolResponse = CreateToolResponse(toolCall);
|
||||||
|
|
||||||
|
// Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we
|
||||||
|
// care, because we do not filter approval content)
|
||||||
|
ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall);
|
||||||
|
ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall);
|
||||||
|
|
||||||
|
FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId);
|
||||||
|
FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2);
|
||||||
|
|
||||||
|
List<ChatMessage> result = [new(ChatRole.User, "Hello")];
|
||||||
|
|
||||||
|
// Agent 1 turn
|
||||||
|
result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?"));
|
||||||
|
result.Add(new(ChatRole.User, "Please explain temperature"));
|
||||||
|
|
||||||
|
// Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally
|
||||||
|
if (filter == HandoffToolCallFilteringBehavior.None)
|
||||||
|
{
|
||||||
|
result.Add(new(ChatRole.Assistant, [handoffRequest1]));
|
||||||
|
result.Add(new(ChatRole.Tool, [handoffResponse1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent 2 turn
|
||||||
|
|
||||||
|
// Tool approvals are never filtered, so we add them unconditionally
|
||||||
|
result.Add(new(ChatRole.Assistant, [toolApproval]));
|
||||||
|
result.Add(new(ChatRole.User, [toolApprovalResponse]));
|
||||||
|
|
||||||
|
// Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally
|
||||||
|
if (filter != HandoffToolCallFilteringBehavior.All)
|
||||||
|
{
|
||||||
|
result.Add(new(ChatRole.Assistant, [toolCall]));
|
||||||
|
result.Add(new(ChatRole.Tool, [toolResponse]));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance."));
|
||||||
|
|
||||||
|
if (filter == HandoffToolCallFilteringBehavior.None)
|
||||||
|
{
|
||||||
|
result.Add(new(ChatRole.Assistant, [handoffRequest2]));
|
||||||
|
result.Add(new(ChatRole.Tool, [handoffResponse2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FunctionCallContent CreateHandoffCall(int id, bool useCallId)
|
||||||
|
{
|
||||||
|
string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}";
|
||||||
|
string callId = useCallId ? Guid.NewGuid().ToString("N") : callName;
|
||||||
|
|
||||||
|
return new FunctionCallContent(callId, callName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call)
|
||||||
|
=> HandoffAgentExecutor.CreateHandoffResult(call.CallId);
|
||||||
|
|
||||||
|
private static FunctionCallContent CreateToolCall(bool useCallId)
|
||||||
|
{
|
||||||
|
const string CallName = "ToolFunction";
|
||||||
|
string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName;
|
||||||
|
|
||||||
|
return new FunctionCallContent(callId, CallName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FunctionResultContent CreateToolResponse(FunctionCallContent call)
|
||||||
|
=> new(call.CallId, new object());
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, true, HandoffToolCallFilteringBehavior.None)]
|
||||||
|
[InlineData(true, false, HandoffToolCallFilteringBehavior.None)]
|
||||||
|
[InlineData(false, true, HandoffToolCallFilteringBehavior.None)]
|
||||||
|
[InlineData(false, false, HandoffToolCallFilteringBehavior.None)]
|
||||||
|
[InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||||
|
[InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||||
|
[InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||||
|
[InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
|
||||||
|
[InlineData(true, true, HandoffToolCallFilteringBehavior.All)]
|
||||||
|
[InlineData(true, false, HandoffToolCallFilteringBehavior.All)]
|
||||||
|
[InlineData(false, true, HandoffToolCallFilteringBehavior.All)]
|
||||||
|
[InlineData(false, false, HandoffToolCallFilteringBehavior.All)]
|
||||||
|
public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
List<ChatMessage> messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId);
|
||||||
|
List<ChatMessage> expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior);
|
||||||
|
|
||||||
|
HandoffMessagesFilter filter = new(behavior);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
IEnumerable<ChatMessage> filteredMessages = filter.FilterMessages(messages);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
filteredMessages.Should().BeEquivalentTo(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,7 @@ from ._telemetry import (
|
|||||||
APP_INFO,
|
APP_INFO,
|
||||||
USER_AGENT_KEY,
|
USER_AGENT_KEY,
|
||||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||||
|
get_user_agent_extra_headers,
|
||||||
prepend_agent_framework_to_user_agent,
|
prepend_agent_framework_to_user_agent,
|
||||||
)
|
)
|
||||||
from ._tools import (
|
from ._tools import (
|
||||||
@@ -422,6 +423,7 @@ __all__ = [
|
|||||||
"evaluator",
|
"evaluator",
|
||||||
"executor",
|
"executor",
|
||||||
"function_middleware",
|
"function_middleware",
|
||||||
|
"get_user_agent_extra_headers",
|
||||||
"handler",
|
"handler",
|
||||||
"included_messages",
|
"included_messages",
|
||||||
"included_token_count",
|
"included_token_count",
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ def _get_user_agent() -> str:
|
|||||||
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
|
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_agent_extra_headers() -> dict[str, str]:
|
||||||
|
"""Return extra headers containing the current User-Agent string for per-request injection.
|
||||||
|
|
||||||
|
This function evaluates the user agent at call time, picking up any active
|
||||||
|
``user_agent_prefix`` context. Use it to supply ``extra_headers`` on individual
|
||||||
|
API calls so that the User-Agent reflects the current functional area.
|
||||||
|
|
||||||
|
When user agent telemetry is disabled, an empty dict is returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``"User-Agent"`` set to the runtime user agent string,
|
||||||
|
or an empty dict when telemetry is disabled.
|
||||||
|
"""
|
||||||
|
if not IS_TELEMETRY_ENABLED:
|
||||||
|
return {}
|
||||||
|
return {USER_AGENT_KEY: _get_user_agent()}
|
||||||
|
|
||||||
|
|
||||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
"""Prepend "agent-framework" to the User-Agent in the headers.
|
"""Prepend "agent-framework" to the User-Agent in the headers.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from agent_framework import (
|
|||||||
AGENT_FRAMEWORK_USER_AGENT,
|
AGENT_FRAMEWORK_USER_AGENT,
|
||||||
USER_AGENT_KEY,
|
USER_AGENT_KEY,
|
||||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||||
|
get_user_agent_extra_headers,
|
||||||
prepend_agent_framework_to_user_agent,
|
prepend_agent_framework_to_user_agent,
|
||||||
)
|
)
|
||||||
from agent_framework._telemetry import user_agent_prefix
|
from agent_framework._telemetry import user_agent_prefix
|
||||||
@@ -150,3 +151,33 @@ def test_user_agent_prefix_nesting():
|
|||||||
# Both removed
|
# Both removed
|
||||||
result = prepend_agent_framework_to_user_agent()
|
result = prepend_agent_framework_to_user_agent()
|
||||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
# region Test get_user_agent_extra_headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_agent_extra_headers_returns_user_agent():
|
||||||
|
"""Test that get_user_agent_extra_headers returns a User-Agent header."""
|
||||||
|
result = get_user_agent_extra_headers()
|
||||||
|
assert "User-Agent" in result
|
||||||
|
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_agent_extra_headers_with_prefix():
|
||||||
|
"""Test that get_user_agent_extra_headers respects user_agent_prefix context."""
|
||||||
|
with user_agent_prefix("test-host"):
|
||||||
|
result = get_user_agent_extra_headers()
|
||||||
|
assert result["User-Agent"].startswith("test-host/")
|
||||||
|
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
|
||||||
|
|
||||||
|
# After exiting context, prefix is removed
|
||||||
|
result = get_user_agent_extra_headers()
|
||||||
|
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_agent_extra_headers_with_nested_prefix():
|
||||||
|
"""Test that get_user_agent_extra_headers picks up nested prefixes."""
|
||||||
|
with user_agent_prefix("outer"), user_agent_prefix("inner"):
|
||||||
|
result = get_user_agent_extra_headers()
|
||||||
|
assert "outer" in result["User-Agent"]
|
||||||
|
assert "inner" in result["User-Agent"]
|
||||||
|
|||||||
@@ -664,6 +664,21 @@ def test_function_approval_serialization_roundtrip():
|
|||||||
# The Content union will need to be handled differently when we fully migrate
|
# The Content union will need to be handled differently when we fully migrate
|
||||||
|
|
||||||
|
|
||||||
|
def test_function_approval_request_function_call_none_guard():
|
||||||
|
"""Test that accessing function_call attributes is safe when function_call is None."""
|
||||||
|
# Construct a Content with type "function_approval_request" but no function_call.
|
||||||
|
# This verifies the None-guard pattern used in samples to prevent AttributeError.
|
||||||
|
content = Content("function_approval_request", id="req-none")
|
||||||
|
assert content.function_call is None
|
||||||
|
|
||||||
|
# A proper approval request always has function_call set
|
||||||
|
fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1})
|
||||||
|
req = Content.from_function_approval_request(id="req-1", function_call=fc)
|
||||||
|
assert req.function_call is not None
|
||||||
|
assert req.function_call.name == "do_something"
|
||||||
|
assert req.function_call.arguments == {"a": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_function_approval_accepts_mcp_call():
|
def test_function_approval_accepts_mcp_call():
|
||||||
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
|
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
|
||||||
mcp_call = Content.from_mcp_server_tool_call(
|
mcp_call = Content.from_mcp_server_tool_call(
|
||||||
|
|||||||
@@ -915,3 +915,76 @@ class TestToMessage:
|
|||||||
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
# region User Agent Prefix
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserAgentPrefix:
|
||||||
|
"""Tests that the user_agent_prefix context manager is active during agent execution."""
|
||||||
|
|
||||||
|
async def test_user_agent_prefix_set_during_non_streaming(self) -> None:
|
||||||
|
"""The user agent should contain the foundry-hosting prefix in non-streaming mode."""
|
||||||
|
from agent_framework._telemetry import _get_user_agent # type: ignore
|
||||||
|
|
||||||
|
captured_user_agent: list[str] = []
|
||||||
|
|
||||||
|
async def run_and_capture(*args: Any, **kwargs: Any) -> AgentResponse:
|
||||||
|
captured_user_agent.append(_get_user_agent())
|
||||||
|
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||||
|
|
||||||
|
agent = _make_agent()
|
||||||
|
agent.run = AsyncMock(side_effect=run_and_capture)
|
||||||
|
server = _make_server(agent)
|
||||||
|
resp = await _post(server, input_text="Hi", stream=False)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(captured_user_agent) == 1
|
||||||
|
assert "foundry-hosting" in captured_user_agent[0]
|
||||||
|
|
||||||
|
async def test_user_agent_prefix_set_during_streaming(self) -> None:
|
||||||
|
"""The user agent should contain the foundry-hosting prefix in streaming mode."""
|
||||||
|
from agent_framework._telemetry import _get_user_agent # type: ignore
|
||||||
|
|
||||||
|
captured_user_agent: list[str] = []
|
||||||
|
|
||||||
|
async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||||
|
captured_user_agent.append(_get_user_agent())
|
||||||
|
yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant")
|
||||||
|
|
||||||
|
def run_streaming(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
if kwargs.get("stream"):
|
||||||
|
return ResponseStream(_stream_gen()) # type: ignore
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
agent = _make_agent()
|
||||||
|
agent.run = MagicMock(side_effect=run_streaming)
|
||||||
|
server = _make_server(agent)
|
||||||
|
resp = await _post(server, stream=True)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(captured_user_agent) == 1
|
||||||
|
assert "foundry-hosting" in captured_user_agent[0]
|
||||||
|
|
||||||
|
async def test_user_agent_extra_headers_during_run(self) -> None:
|
||||||
|
"""get_user_agent_extra_headers() should include the prefix during a request."""
|
||||||
|
from agent_framework._telemetry import get_user_agent_extra_headers
|
||||||
|
|
||||||
|
captured_headers: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
async def run_and_capture(*args: Any, **kwargs: Any) -> AgentResponse:
|
||||||
|
captured_headers.append(get_user_agent_extra_headers())
|
||||||
|
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||||
|
|
||||||
|
agent = _make_agent()
|
||||||
|
agent.run = AsyncMock(side_effect=run_and_capture)
|
||||||
|
server = _make_server(agent)
|
||||||
|
resp = await _post(server, input_text="Hi", stream=False)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(captured_headers) == 1
|
||||||
|
assert "User-Agent" in captured_headers[0]
|
||||||
|
assert "foundry-hosting" in captured_headers[0]["User-Agent"]
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from agent_framework._clients import BaseChatClient
|
|||||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||||
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
|
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
|
||||||
from agent_framework._settings import SecretString
|
from agent_framework._settings import SecretString
|
||||||
from agent_framework._telemetry import USER_AGENT_KEY
|
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
|
||||||
from agent_framework._tools import (
|
from agent_framework._tools import (
|
||||||
SHELL_TOOL_KIND_VALUE,
|
SHELL_TOOL_KIND_VALUE,
|
||||||
FunctionInvocationConfiguration,
|
FunctionInvocationConfiguration,
|
||||||
@@ -482,6 +482,13 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
|||||||
client = self.client
|
client = self.client
|
||||||
validated_options = await self._validate_options(options)
|
validated_options = await self._validate_options(options)
|
||||||
run_options = await self._prepare_options(messages, validated_options)
|
run_options = await self._prepare_options(messages, validated_options)
|
||||||
|
ua_headers = get_user_agent_extra_headers()
|
||||||
|
if ua_headers:
|
||||||
|
existing = run_options.get("extra_headers")
|
||||||
|
if existing is None:
|
||||||
|
run_options["extra_headers"] = ua_headers
|
||||||
|
elif USER_AGENT_KEY not in existing:
|
||||||
|
run_options["extra_headers"] = {**existing, **ua_headers}
|
||||||
return client, run_options, validated_options
|
return client, run_options, validated_options
|
||||||
|
|
||||||
def _handle_request_error(self, ex: Exception) -> NoReturn:
|
def _handle_request_error(self, ex: Exception) -> NoReturn:
|
||||||
@@ -525,6 +532,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
|||||||
stream_response = await client.responses.retrieve(
|
stream_response = await client.responses.retrieve(
|
||||||
continuation_token["response_id"],
|
continuation_token["response_id"],
|
||||||
stream=True,
|
stream=True,
|
||||||
|
extra_headers=get_user_agent_extra_headers(),
|
||||||
)
|
)
|
||||||
async for chunk in stream_response:
|
async for chunk in stream_response:
|
||||||
yield self._parse_chunk_from_openai(
|
yield self._parse_chunk_from_openai(
|
||||||
@@ -572,7 +580,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
|||||||
client = self.client
|
client = self.client
|
||||||
validated_options = await self._validate_options(options)
|
validated_options = await self._validate_options(options)
|
||||||
try:
|
try:
|
||||||
response = await client.responses.retrieve(continuation_token["response_id"])
|
response = await client.responses.retrieve(
|
||||||
|
continuation_token["response_id"],
|
||||||
|
extra_headers=get_user_agent_extra_headers(),
|
||||||
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._handle_request_error(ex)
|
self._handle_request_error(ex)
|
||||||
return self._parse_response_from_openai(response, options=validated_options)
|
return self._parse_response_from_openai(response, options=validated_options)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
|||||||
from agent_framework._docstrings import apply_layered_docstring
|
from agent_framework._docstrings import apply_layered_docstring
|
||||||
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
|
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
|
||||||
from agent_framework._settings import SecretString
|
from agent_framework._settings import SecretString
|
||||||
from agent_framework._telemetry import USER_AGENT_KEY
|
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
|
||||||
from agent_framework._tools import (
|
from agent_framework._tools import (
|
||||||
FunctionInvocationConfiguration,
|
FunctionInvocationConfiguration,
|
||||||
FunctionInvocationLayer,
|
FunctionInvocationLayer,
|
||||||
@@ -671,6 +671,16 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
|
|||||||
run_options["response_format"] = response_format
|
run_options["response_format"] = response_format
|
||||||
else:
|
else:
|
||||||
run_options["response_format"] = type_to_response_format_param(response_format)
|
run_options["response_format"] = type_to_response_format_param(response_format)
|
||||||
|
|
||||||
|
# runtime user-agent header
|
||||||
|
ua_headers = get_user_agent_extra_headers()
|
||||||
|
if ua_headers:
|
||||||
|
existing = run_options.get("extra_headers")
|
||||||
|
if existing is None:
|
||||||
|
run_options["extra_headers"] = ua_headers
|
||||||
|
elif USER_AGENT_KEY not in existing:
|
||||||
|
run_options["extra_headers"] = {**existing, **ua_headers}
|
||||||
|
|
||||||
return run_options
|
return run_options
|
||||||
|
|
||||||
def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping[str, Any]) -> ChatResponse:
|
def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping[str, Any]) -> ChatResponse:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypedDict, ov
|
|||||||
|
|
||||||
from agent_framework._clients import BaseEmbeddingClient
|
from agent_framework._clients import BaseEmbeddingClient
|
||||||
from agent_framework._settings import SecretString
|
from agent_framework._settings import SecretString
|
||||||
from agent_framework._telemetry import USER_AGENT_KEY
|
from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent_extra_headers
|
||||||
from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails
|
from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails
|
||||||
from agent_framework.observability import EmbeddingTelemetryLayer
|
from agent_framework.observability import EmbeddingTelemetryLayer
|
||||||
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
||||||
@@ -282,6 +282,13 @@ class RawOpenAIEmbeddingClient(
|
|||||||
kwargs["encoding_format"] = encoding_format
|
kwargs["encoding_format"] = encoding_format
|
||||||
if user := opts.get("user"):
|
if user := opts.get("user"):
|
||||||
kwargs["user"] = user
|
kwargs["user"] = user
|
||||||
|
ua_headers = get_user_agent_extra_headers()
|
||||||
|
if ua_headers:
|
||||||
|
existing = kwargs.get("extra_headers")
|
||||||
|
if existing is None:
|
||||||
|
kwargs["extra_headers"] = ua_headers
|
||||||
|
elif USER_AGENT_KEY not in existing:
|
||||||
|
kwargs["extra_headers"] = {**existing, **ua_headers}
|
||||||
|
|
||||||
response = await self.client.embeddings.create(**kwargs) # type: ignore[union-attr]
|
response = await self.client.embeddings.create(**kwargs) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from copy import copy
|
|||||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||||
|
|
||||||
from agent_framework._settings import SecretString, load_settings
|
from agent_framework._settings import SecretString, load_settings
|
||||||
from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
|
from agent_framework._telemetry import APP_INFO
|
||||||
from agent_framework.exceptions import SettingNotFoundError
|
from agent_framework.exceptions import SettingNotFoundError
|
||||||
from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore
|
from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore
|
||||||
from openai.types import Completion
|
from openai.types import Completion
|
||||||
@@ -174,7 +174,6 @@ def load_openai_service_settings(
|
|||||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||||
if APP_INFO:
|
if APP_INFO:
|
||||||
merged_headers.update(APP_INFO)
|
merged_headers.update(APP_INFO)
|
||||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
|
||||||
|
|
||||||
api_key_callable = api_key if callable(api_key) else None
|
api_key_callable = api_key if callable(api_key) else None
|
||||||
api_key_str = api_key if not callable(api_key) else None
|
api_key_str = api_key if not callable(api_key) else None
|
||||||
|
|||||||
@@ -29,19 +29,19 @@ approval will pause the workflow until the human responds.
|
|||||||
|
|
||||||
This sample works as follows:
|
This sample works as follows:
|
||||||
1. A ConcurrentBuilder workflow is created with two agents running in parallel.
|
1. A ConcurrentBuilder workflow is created with two agents running in parallel.
|
||||||
2. Both agents have the same tools, including one requiring approval (execute_trade).
|
2. Both agents have the same tools, including two requiring approval (execute_trade, set_stop_loss).
|
||||||
3. Both agents receive the same task and work concurrently on their respective stocks.
|
3. Both agents receive the same task and work concurrently on their respective stocks.
|
||||||
4. When either agent tries to execute a trade, it triggers an approval request.
|
4. When either agent tries to execute a trade or set a stop-loss, it triggers an approval request.
|
||||||
5. The sample simulates human approval and the workflow completes.
|
5. The sample simulates human approval and the workflow completes.
|
||||||
6. Results from both agents are aggregated and output.
|
6. Results from both agents are aggregated and output.
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Show how tool call approvals work in parallel execution scenarios where multiple
|
Show how tool call approvals work in parallel execution scenarios where multiple
|
||||||
agents may independently trigger approval requests.
|
agents may independently trigger approval requests for different tools.
|
||||||
|
|
||||||
Demonstrate:
|
Demonstrate:
|
||||||
- Handling multiple approval requests from different agents in concurrent workflows.
|
- Handling multiple approval requests from different agents in concurrent workflows.
|
||||||
- Handling during concurrent agent execution.
|
- Handling approval requests for different tools during concurrent agent execution.
|
||||||
- Understanding that approval pauses only the agent that triggered it, not all agents.
|
- Understanding that approval pauses only the agent that triggered it, not all agents.
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
@@ -89,6 +89,15 @@ def execute_trade(
|
|||||||
return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}"
|
return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool(approval_mode="always_require")
|
||||||
|
def set_stop_loss(
|
||||||
|
symbol: Annotated[str, "The stock ticker symbol"],
|
||||||
|
stop_price: Annotated[float, "The stop-loss price"],
|
||||||
|
) -> str:
|
||||||
|
"""Set a stop-loss order for a stock. Requires human approval due to financial impact."""
|
||||||
|
return f"Stop-loss set for {symbol.upper()} at ${stop_price:.2f}"
|
||||||
|
|
||||||
|
|
||||||
@tool(approval_mode="never_require")
|
@tool(approval_mode="never_require")
|
||||||
def get_portfolio_balance() -> str:
|
def get_portfolio_balance() -> str:
|
||||||
"""Get current portfolio balance and available funds."""
|
"""Get current portfolio balance and available funds."""
|
||||||
@@ -118,14 +127,17 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
|
|||||||
if event.type == "request_info" and isinstance(event.data, Content):
|
if event.type == "request_info" and isinstance(event.data, Content):
|
||||||
# We are only expecting tool approval requests in this sample
|
# We are only expecting tool approval requests in this sample
|
||||||
requests[event.request_id] = event.data
|
requests[event.request_id] = event.data
|
||||||
|
if event.data.type == "function_approval_request" and event.data.function_call is not None:
|
||||||
|
print(f"\nApproval requested for tool: {event.data.function_call.name}")
|
||||||
|
print(f"Arguments: {event.data.function_call.arguments}")
|
||||||
elif event.type == "output":
|
elif event.type == "output":
|
||||||
_print_output(event)
|
_print_output(event)
|
||||||
|
|
||||||
responses: dict[str, Content] = {}
|
responses: dict[str, Content] = {}
|
||||||
if requests:
|
if requests:
|
||||||
for request_id, request in requests.items():
|
for request_id, request in requests.items():
|
||||||
if request.type == "function_approval_request":
|
if request.type == "function_approval_request" and request.function_call is not None:
|
||||||
print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore
|
print(f"\nSimulating human approval for: {request.function_call.name}")
|
||||||
# Create approval response
|
# Create approval response
|
||||||
responses[request_id] = request.to_function_approval_response(approved=True)
|
responses[request_id] = request.to_function_approval_response(approved=True)
|
||||||
|
|
||||||
@@ -145,9 +157,10 @@ async def main() -> None:
|
|||||||
name="MicrosoftAgent",
|
name="MicrosoftAgent",
|
||||||
instructions=(
|
instructions=(
|
||||||
"You are a personal trading assistant focused on Microsoft (MSFT). "
|
"You are a personal trading assistant focused on Microsoft (MSFT). "
|
||||||
"You manage my portfolio and take actions based on market data."
|
"You manage my portfolio and take actions based on market data. "
|
||||||
|
"Use stop-loss orders to manage risk."
|
||||||
),
|
),
|
||||||
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
|
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
|
||||||
)
|
)
|
||||||
|
|
||||||
google_agent = Agent(
|
google_agent = Agent(
|
||||||
@@ -155,9 +168,10 @@ async def main() -> None:
|
|||||||
name="GoogleAgent",
|
name="GoogleAgent",
|
||||||
instructions=(
|
instructions=(
|
||||||
"You are a personal trading assistant focused on Google (GOOGL). "
|
"You are a personal trading assistant focused on Google (GOOGL). "
|
||||||
"You manage my trades and portfolio based on market conditions."
|
"You manage my trades and portfolio based on market conditions. "
|
||||||
|
"Use stop-loss orders to manage risk."
|
||||||
),
|
),
|
||||||
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
|
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Build a concurrent workflow with both agents
|
# 4. Build a concurrent workflow with both agents
|
||||||
@@ -172,7 +186,8 @@ async def main() -> None:
|
|||||||
# Runs are not isolated; state is preserved across multiple calls to run.
|
# Runs are not isolated; state is preserved across multiple calls to run.
|
||||||
stream = workflow.run(
|
stream = workflow.run(
|
||||||
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
|
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
|
||||||
"your best judgment based on market sentiment. No need to confirm trades with me.",
|
"your best judgment based on market sentiment. Set stop-loss orders to manage risk. "
|
||||||
|
"No need to confirm trades with me.",
|
||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,22 +206,32 @@ async def main() -> None:
|
|||||||
Approval requested for tool: execute_trade
|
Approval requested for tool: execute_trade
|
||||||
Arguments: {"symbol":"MSFT","action":"buy","quantity":13}
|
Arguments: {"symbol":"MSFT","action":"buy","quantity":13}
|
||||||
|
|
||||||
|
Approval requested for tool: set_stop_loss
|
||||||
|
Arguments: {"symbol":"MSFT","stop_price":340.0}
|
||||||
|
|
||||||
Approval requested for tool: execute_trade
|
Approval requested for tool: execute_trade
|
||||||
Arguments: {"symbol":"GOOGL","action":"buy","quantity":35}
|
Arguments: {"symbol":"GOOGL","action":"buy","quantity":35}
|
||||||
|
|
||||||
Simulating human approval for: execute_trade
|
Approval requested for tool: set_stop_loss
|
||||||
|
Arguments: {"symbol":"GOOGL","stop_price":126.0}
|
||||||
|
|
||||||
Simulating human approval for: execute_trade
|
Simulating human approval for: execute_trade
|
||||||
|
|
||||||
|
Simulating human approval for: set_stop_loss
|
||||||
|
|
||||||
|
Simulating human approval for: execute_trade
|
||||||
|
|
||||||
|
Simulating human approval for: set_stop_loss
|
||||||
|
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
Workflow completed. Aggregated results from both agents:
|
Workflow completed. Aggregated results from both agents:
|
||||||
- user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on
|
- user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on
|
||||||
market sentiment. No need to confirm trades with me.
|
market sentiment. Set stop-loss orders to manage risk. No need to confirm trades with me.
|
||||||
- MicrosoftAgent: I have successfully executed the trade, purchasing 13 shares of Microsoft (MSFT). This action
|
- MicrosoftAgent: I have successfully purchased 13 shares of Microsoft (MSFT) and set a stop-loss at $340.00.
|
||||||
was based on the positive market sentiment and available funds within the specified limit.
|
This action was based on the positive market sentiment and available funds within the
|
||||||
Your portfolio has been adjusted accordingly.
|
specified limit. Your portfolio has been adjusted accordingly.
|
||||||
- GoogleAgent: I have successfully executed the trade, purchasing 35 shares of GOOGL. If you need further
|
- GoogleAgent: I have successfully purchased 35 shares of GOOGL and set a stop-loss at $126.00. If you need
|
||||||
assistance or any adjustments, feel free to ask!
|
further assistance or any adjustments, feel free to ask!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
|
|||||||
responses: dict[str, Content] = {}
|
responses: dict[str, Content] = {}
|
||||||
if requests:
|
if requests:
|
||||||
for request_id, request in requests.items():
|
for request_id, request in requests.items():
|
||||||
if request.type == "function_approval_request":
|
if request.type == "function_approval_request" and request.function_call is not None:
|
||||||
print("\n[APPROVAL REQUIRED]")
|
print("\n[APPROVAL REQUIRED]")
|
||||||
print(f" Tool: {request.function_call.name}") # type: ignore
|
print(f" Tool: {request.function_call.name}")
|
||||||
print(f" Arguments: {request.function_call.arguments}") # type: ignore
|
print(f" Arguments: {request.function_call.arguments}")
|
||||||
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
|
print(f"Simulating human approval for: {request.function_call.name}")
|
||||||
# Create approval response
|
# Create approval response
|
||||||
responses[request_id] = request.to_function_approval_response(approved=True)
|
responses[request_id] = request.to_function_approval_response(approved=True)
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
|
|||||||
responses: dict[str, Content] = {}
|
responses: dict[str, Content] = {}
|
||||||
if requests:
|
if requests:
|
||||||
for request_id, request in requests.items():
|
for request_id, request in requests.items():
|
||||||
if request.type == "function_approval_request":
|
if request.type == "function_approval_request" and request.function_call is not None:
|
||||||
print("\n[APPROVAL REQUIRED]")
|
print("\n[APPROVAL REQUIRED]")
|
||||||
print(f" Tool: {request.function_call.name}") # type: ignore
|
print(f" Tool: {request.function_call.name}")
|
||||||
print(f" Arguments: {request.function_call.arguments}") # type: ignore
|
print(f" Arguments: {request.function_call.arguments}")
|
||||||
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
|
print(f"Simulating human approval for: {request.function_call.name}")
|
||||||
# Create approval response
|
# Create approval response
|
||||||
responses[request_id] = request.to_function_approval_response(approved=True)
|
responses[request_id] = request.to_function_approval_response(approved=True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user